Skip to content

Design: Server-side forge-write scope enforcement (A8, Beta tier)

Status: Draft Owner lane: compass-server. Refs: RIG-2679 (this record), RIG-2672 (multi-forge widened the blast radius), RIG-2682 (account model — this record is deliberately independent of its outcome).

The forge-write chokepoint ships no server-side scope rejection. The trust model at the seam says so explicitly:

Per Resolved decision 2 (MVP, single-trust-domain) the caller is recorded for attribution but NO scope rejection ships (A8). — go/server/forge.go:16-18

The frozen forge-write-path record pinned the same posture:

Authz posture (A8): inherited from the board path — “MVP scope ships no scope rejection (single-trust-domain, Resolved decision 2)” (relay_board.go:37-38); no per-op scope check in v1. — docs/designs/product/compass-forge-write-path/design.md:697-699

Meanwhile the credential key deliberately excludes repo:

forgeCoordinate is the registry key: the wire forge enum + host. A repo does NOT enter the key — one credential pair serves every repo on a coordinate (DL-091 multi-forge disambiguation is provider+host). — go/server/forge.go:46-48

So one shared credential pair serves every repo the token can reach, and for Linear repo is a team key — “repo is the Linear TEAM KEY (e.g. “SEA”), not owner/name“ (go/internal/forge/linear.go:11) — so the RIG-2672 multi-forge coordinate (buildForgeWriteService registers a Linear coordinate beside GitHub whenever LINEAR_FORGE_TOKEN is declared, go/server/serve.go:965-970) doubled the blast radius: a hallucinated or prompt-injected repo string in a ForgeCallRequest writes into any GitHub repo and any Linear team the shared credential reaches, attributed but never rejected.

Matt ruled server-side scope enforcement MANDATORY and UNCONDITIONAL for the Beta tier, regardless of the RIG-2682 account-model outcome. The Dogfood tier still defers it (single trust domain — one operator owns every agent and every credential). This record designs the Beta gate and its Dogfood off switch. It is server-authz work only: the TS tool leg already sends repo and is not reworked.

  • Go, go/ module; the chokepoint is package server (go/server/forge.go).
  • Rejection is in-band, never a Connect error: a tool-level refusal rides the ForgeCallResult_Error arm the agent renders — “ONLY a malformed request (an unset oneof arm) or a missing caller resolution is a Connect error” (go/server/forge.go:22-24). The helpers exist: forgeErr(code connect.Code, msg string) (go/server/forge.go:655-657) and forgeErrorResult(fe) (go/server/forge.go:662-664).
  • The not-found/forbidden merge is house style: an unauthorized target is indistinguishable from a nonexistent one, “so a probe enumerates nothing” (go/internal/store/authz.go:13-15); the forge error mapper already flattens provider 403 ≡ 404 to a byte-identical not_found (go/server/forge.go:602-604).
  • Store access from the chokepoint goes through the narrow forgeStore interface (go/server/forge.go:140-144) so the ordering is provable against fakeForgeStore in the default test lane (go/server/forge_test.go:48-51), with pgtest proving the real backend (DL-174 differential-oracle pyramid).
  • Migrations: additive SQL in go/internal/store/migrations/; text ids, FK ON DELETE RESTRICT, coordinate columns aligned to the 0013 convention (SMALLINT provider CHECK IN (1,2,3,4) + forge_host in every key, 0001_init.sql:604-608).
  • Ledger: this record proposes its DL row below; the driver assembles the final id into DECISIONS.md at PR-assembly time. Do not edit DECISIONS.md from this record.
  • Red → green: every task lands its failing test first.

One sentence: a per-account forge-scope allowlist table consulted by a new requireForgeScope step in every write arm of ExecuteForgeCallAsAccount, after coordinate resolution (and, on the create arms, after the F3 idempotency-memo check — a memo hit writes nothing) and before any provider call, rejecting an out-of-scope (provider, host, repo) as an in-band ForgeCallError{code:"not_found"} — the exact mirror of comms channel-membership write authz — gated on by a ForgeConfig enforcement flag Beta deployments set (the flag’s default direction is OQ-1) and Dogfood leaves off.

The mirror pattern (comms channel membership)

Section titled “The mirror pattern (comms channel membership)”

Comms authorizes every channel write through one store-side primitive:

requireChannelMember is the D9 write-authorization primitive: it verifies the actor is a member of channelID and returns ErrNotFound if not. — go/internal/store/authz.go:8-10

if err := requireChannelMember(ctx, tx, m.AuthorAccountID, ChannelID(channelID)); err != nil {
return Message{}, false, err
}

go/internal/store/messages.go:57-59. The refusal is ErrNotFound (“channel %q”, authz.go:32), never a distinct forbidden. Forge scope enforcement is the same shape with the membership row replaced by a scope row and the tx-querier replaced by the pool (the forge chokepoint holds no store tx; its writes are single statements).

One more comms precedent this design leans on for the grant model:

the actor is authorized when it owns the group, when it is an agent whose owning user owns the group (an agent acts within its owner’s space — Matt’s ruling) … — go/internal/store/authz.go:80-82

A1 — storage: a new account_forge_scopes table

Section titled “A1 — storage: a new account_forge_scopes table”

Neither existing table fits. forge_repo_subscriptions is the board poll target set, deployment-global with no account column (0001_init.sql:616-624) — reusing it would conflate “what the board ingests” with “what an account may write”, and disabling a poll target would silently revoke write scope. agent_forge_subscriptions is per-artifact notification state (0001_init.sql:629-641), not a repo grant. So: a new table, coordinate-aligned to the 0013 convention:

-- RIG-2679 (A8): per-account forge write scope. A row grants account_id the
-- right to write into (forge_provider, forge_host, repo); repo is the Linear
-- team KEY on LINEAR rows. repo = '*' grants the whole coordinate. Grants
-- attach to the OWNING USER account: the chokepoint checks agent-or-owner,
-- so one grant covers a user's whole agent fleet (an agent acts within its
-- owner's space — the requireGroupCreateAuthz precedent, authz.go:80-82);
-- keying on account_id (not user_accounts) keeps a future per-agent narrow
-- additive. GITHUB repo lowercased at the store door (the
-- forge_repo_subscriptions convention, 0001_init.sql:612-614).
CREATE TABLE account_forge_scopes (
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE RESTRICT,
forge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3, 4)),
forge_host TEXT NOT NULL,
repo TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, forge_provider, forge_host, repo)
);

The check is one EXISTS over (agent OR its owner) × (exact repo OR '*'):

SELECT EXISTS (
SELECT 1 FROM account_forge_scopes s
WHERE s.forge_provider = $2 AND s.forge_host = $3
AND s.repo IN ($4, '*')
AND s.account_id IN (
$1,
(SELECT owner_user_id FROM agent_accounts WHERE account_id = $1)))

agent_accounts.owner_user_id is NOT NULL (0001_init.sql:76), and the chokepoint already resolves the same edge for attribution (resolveIdentity, go/server/forge.go:237).

Grant and check MUST agree on case. The grant door lowercases GITHUB repos (the forge_repo_subscriptions convention: “For GITHUB the repo string is lowercased at the seed/upsert boundary”, 0001_init.sql:612-614) and preserves the Linear team key verbatim (repo is the Linear TEAM KEY, e.g. “SEA” — go/internal/forge/linear.go:11 — never case-folded anywhere in the store). HasForgeScope therefore applies the IDENTICAL provider-aware fold to the incoming query repo before the EXISTS — GITHUB lowercased, LINEAR preserved — so a mixed-case injected repo can neither slip past a lowercased grant (fail-open) nor a correctly-granted caller miss its own grant (inconsistently fail-closed).

A2 — population: declarative seed + owner grant, no console clicks

Section titled “A2 — population: declarative seed + owner grant, no console clicks”

Two paths, both agent/IaC-friendly (rule no-human-clicks):

  1. Boot seed (MVP, required): ForgeConfig grows ScopeGrants []string of handle=provider:host/repo entries (repo * allowed), reconciled at serve assembly exactly as SeedRepos reconciles into forge_repo_subscriptions — “bootstrap-only insert, ON CONFLICT DO NOTHING” (go/server/serve.go:1006-1008, go/internal/store/forge_cursors.go:136-148 is the pattern). The deployment’s scope set lives in config, deployed by merge to main.
  2. Owner grant RPC (same slice, small): GrantForgeScope / RevokeForgeScope store methods, exposed later on the admin surface; in this slice they exist for the seed reconciler, SQL-parity operators, and tests (the SetForgeRepoSubscriptionEnabled posture, forge_cursors.go:193-196). Agents never self-grant — a self-declarable allowlist is no allowlist; the granting principal is the owning user (or deployment config), which is what bounds the injected-repo blast radius.

A3 — enforcement point and rejection shape

Section titled “A3 — enforcement point and rejection shape”

ExecuteForgeCallAsAccount dispatches ten arms (go/server/forge.go:188-215). The five write arms (createIssue, createPullRequest, commentOnIssue, commentOnPullRequest, submitReview) each begin with resolveTarget(call, repo) — “It is the first step of every arm” (go/server/forge.go:248-250) — which validates the repo and resolves the coordinate. The gate’s slot differs between the two arm shapes, because only the create arms carry the F3 idempotency memo:

  • Create arms (createIssue, createPullRequest): resolveTargetdedup(memo hit returns the recorded coordinate, zero provider calls, zero scope check)requireForgeScope → identity/stamp/write. The F3 memo lookup (go/server/forge.go:262-274; wired at forge.go:310-313) returns an already-created artifact “with ZERO provider calls” (forge.go:302-304) — it performs no write, so it needs no write scope. Gating BEFORE dedup would break the F3 retry contract: a create committed while enforcement was off (Dogfood), retried after a Dogfood→Beta flip whose grants never seeded that repo, would reject even though the artifact already exists and the memo hit would have returned it writing nothing.
  • Comment/review arms (commentOnIssue, commentOnPullRequest, submitReview): resolveTargetrequireForgeScope → identity/stamp/write. These arms have no dedup step to order against — “the comment/review arms have no coordinate to record, so they never reach here (F3 is create-only per the frozen ruling)” (go/server/forge.go:276-282) — so the gate sits immediately after resolveTarget.

Either way the gate runs before identity resolution, stamping, or any provider touch:

// requireForgeScope is the RIG-2679 (A8) write gate: the caller (or its
// owning user) must hold an account_forge_scopes row for the resolved
// coordinate+repo. Out of scope renders as the byte-fixed in-band not_found
// (the authz.go:13-15 merge; byte-identical to the provider-403/404 flatten
// text, forge.go:627-628), so a probe enumerates nothing. Create arms call
// it AFTER the F3 dedup memo check (a memo hit writes nothing, needs no
// scope); comment/review arms directly after resolveTarget. A nil check on
// s.enforceScopes is the Dogfood defer.
func (s *forgeService) requireForgeScope(ctx context.Context, caller store.AccountID, rf resolvedForge, repo string) *compassv1internal.ForgeCallError
  • In scope / enforcement off → nil, arm proceeds unchanged.
  • Out of scopeforgeErr(connect.CodeNotFound, "forge: artifact not found")byte-identical, as a requirement not a preference, to the text the provider 403 ≡ 404 flatten already emits (go/server/forge.go:627-628; the flatten contract at forge.go:602-604). A prompt-injected probe gets the SAME bytes for out-of-scope, nonexistent, and forbidden, so message text is no oracle to distinguish them. (This resolves the draft’s former rejection-text open question in-design. The unconfigured-coordinate refusal keeps its distinct text, forge.go:257: it varies only with deployment config, never with the probed repo, so it leaks nothing about targets.)
  • Store faultstoreForgeError(err) (go/server/forge.go:638-640), like every other store touch on the path — fail closed (an error is not a pass).

Read arms (getIssue, getPullRequest, listIssues) are NOT gated in this slice: they carry no caller parameter today (go/server/forge.go:472-485 getIssue(ctx, call, req)), Matt’s ruling targets writes, and the read surface leaks only content the shared read credential already exposes to every agent. Extending the gate to reads is OQ-4.

Subscribe/Unsubscribe stay unimplemented in this slice (go/server/forge.go:205-212), and per-arm hand wiring is exactly how a FUTURE write arm ships ungated: the sixth arm lands and nobody remembers the gate. The slice therefore adds a write-arm exhaustiveness test (default lane, beside the per-arm cases): it walks the ForgeCallRequest call oneof’s field descriptors — the same ten arms the dispatch switches over (go/server/forge.go:188-215) — against an explicit in-test classification map (write / read / unimplemented). An arm missing from the map fails the test, so a NEW arm cannot land unclassified; and every write-classified arm is driven with enforcement-on + zero grants, asserting the byte-fixed not_found with zero provider-fake calls, so an UNGATED write arm cannot land green. A fused resolve-and-gate helper was considered and rejected for this job — see Alternatives.

The descriptor walk closes the unclassified-arm gap, not the mis-classified one: a future genuinely-write arm added AND deliberately entered in the read/unimplemented set slips the driven-enforcement leg. T2 hardens this structurally rather than by convention — it asserts the read/unimplemented sets contain only handlers whose signature takes no caller (the read arms carry no caller param, go/server/forge.go:472-485), so a write handler (which does) mis-filed as a read reddens the signature cross-check. That reduces the residual to a write arm that both takes no caller AND writes — a shape the codebase does not have.

Enforcement is a serve-config bit, not a build variant:

  • ForgeConfig.EnforceScopes bool (beside SeedRepos/Poll, go/server/serve.go:108-121), default false = today’s Dogfood posture, zero behavior change for existing deployments — the same all-optional posture ForgeConfig already documents (serve.go:105-107). Whether default-false survives freeze is OQ-1 (load-bearing, deferred to Matt): on a Beta deployment an unset flag fails OPEN — enforcement silently off on the exact tier where it is mandatory.
  • buildForgeWriteService (go/server/serve.go:929-972) threads it into forgeService (a new enforceScopes bool field beside now, go/server/forge.go:151-156).
  • When EnforceScopes is true and ScopeGrants is empty and the table is empty, startup logs a Warn (the warnPartialForgeWriteSecrets posture, serve.go:899): enforcement-on with zero grants means every write rejects, which is fail-closed and legal but probably an operator mistake.
  • The Beta deployment profile sets EnforceScopes: true; there is no code fork between tiers, only config.
  • Prompt-level-only (status quo A8). Rejected for Beta by ruling: the tool prompt’s capability matrix is advice to a model, not authz; a hallucinated/injected repo sails through (forge.go:16-18 records attribution only).
  • Repo in the credential key (per-repo credentials in forgeProviderRegistry). Rejected: reverses DL-091’s provider+host key (forge.go:46-48), multiplies secrets per repo, and still needs an account→credential map — strictly more moving parts than a scope row.
  • Reuse forge_repo_subscriptions as the allowlist. Rejected: it is the board’s poll target set, per-deployment not per-account (0001_init.sql:610-624); coupling ingestion targets to write authz makes “stop polling a repo” silently mean “revoke writes”, and gives every account identical scope — no blast-radius reduction between agents of different owners.
  • Per-agent-only grants (no owner inheritance). Deferred, not rejected: the schema (keyed on bare account_id) admits it additively; MVP checks agent-or-owner because grants-per-owner match the standing “an agent acts within its owner’s space” ruling (authz.go:80-82) and keep the grant set administrable. OQ-3.
  • A fused resolveWriteTarget helper (resolveTarget + scope gate as one call every write arm must use). Rejected as the anti-bypass choke: the create arms gate AFTER the F3 dedup while the comment/review arms gate right after resolveTarget (§A3), so one fused call cannot sit in one place — it would need two shapes or a mode flag, which is the per-arm wiring problem wearing a helper’s name. The file also already prefers the explicit per-arm parallel over extracted helpers on these very arms (“a closure-extracted helper reads worse than the explicit parallel”, go/server/forge.go:378). The bypass risk is carried by the §A3 write-arm exhaustiveness test instead, which catches an ungated or unclassified new arm at the oneof-descriptor level.
  • Connect PermissionDenied instead of in-band. Rejected: violates the frozen in-band/Connect split (forge.go:20-27) and un-merges forbidden-from-not-found, giving an injected prompt a probe oracle.

T1 [compass-server] — store: account_forge_scopes table + scope check

Section titled “T1 [compass-server] — store: account_forge_scopes table + scope check”

Migration (new 000N_account_forge_scopes.sql, next free number) with the A1 DDL. Store surface in a new go/internal/store/forge_scopes.go:

Interfaces:

// ForgeScope is one write-scope grant row.
type ForgeScope struct {
AccountID AccountID
Provider ForgeProvider
Host string
Repo string // "*" grants the whole coordinate
}
// GrantForgeScope inserts idempotently (ON CONFLICT DO NOTHING); GITHUB repo
// lowercased; zero/empty fields -> ErrInvalidArgument.
func (s *Store) GrantForgeScope(ctx context.Context, g ForgeScope) error
// RevokeForgeScope deletes one grant; unknown row -> ErrNotFound.
func (s *Store) RevokeForgeScope(ctx context.Context, g ForgeScope) error
// HasForgeScope reports whether account (or, for an agent, its owning user)
// holds a grant for (provider, host, repo) — exact repo or '*'. repo is
// normalized with the SAME provider-aware fold GrantForgeScope applies
// (GITHUB lowercased, LINEAR team key preserved) before comparison, so
// grant and check always agree on case (§A1).
func (s *Store) HasForgeScope(ctx context.Context, account AccountID, provider ForgeProvider, host, repo string) (bool, error)

Tests: pgtest suite (grant/revoke idempotency, agent-inherits-owner, '*' wildcard, case fold on BOTH sides — a mixed-case GITHUB query repo matches a lowercased grant, a LINEAR team key matches verbatim — FK RESTRICT) mirroring forge_cursors_pgtest_test.go’s shape.

T2 [compass-server] — chokepoint: requireForgeScope in the write arms

Section titled “T2 [compass-server] — chokepoint: requireForgeScope in the write arms”

Interfaces:

// forgeStore (go/server/forge.go:140-144) gains:
HasForgeScope(ctx context.Context, account store.AccountID, provider store.ForgeProvider, host, repo string) (bool, error)
// forgeService (forge.go:151-156) gains: enforceScopes bool
// newForgeService (forge.go:163-165) gains the flag:
func newForgeService(st *store.Store, issueBrd *board.IssueProjection, providers *forgeProviderRegistry, enforceScopes bool) *forgeService
func (s *forgeService) requireForgeScope(ctx context.Context, caller store.AccountID, rf resolvedForge, repo string) *compassv1internal.ForgeCallError

Wire requireForgeScope per the §A3 asymmetry: in the create arms AFTER the F3 dedup memo check (createIssue forge.go:302-334, dedup at :310-313; createPullRequest :336-373, dedup at :343-347) so a memo hit still returns the recorded coordinate writing nothing; in the comment/review arms directly after resolveTarget (commentOnIssue :375-398, commentOnPullRequest :400-421, submitReview :423-467 — no dedup to order against, F3 is create-only, forge.go:276-282). Update the forge.go:13-18 header comment: the A8 posture line becomes “scope enforcement per RIG-2679, gated by enforceScopes”.

Tests (default lane, red first): extend fakeForgeStore (forge_test.go:48-51) with a scope set; per write arm assert (a) enforcement-off passes with zero scope rows, (b) enforcement-on + out-of-scope rejects with the byte-fixed in-band not_found (byte-identical to the 403 ≡ 404 flatten text, §A3) and the provider fake records zero calls and no DL-055 row lands, (c) enforcement-on + exact-repo and '*' grants pass, (d) store fault maps via storeForgeError, (e) read arms unaffected, (f) a create whose client_request_id has a memo hit returns the recorded coordinate with enforcement ON and ZERO grants (the F3 retry contract, §A3). Plus the §A3 write-arm exhaustiveness test over the ForgeCallRequest oneof descriptors (an unclassified or ungated new arm turns it red), paired with the §A3 signature cross-check that the read/unimplemented classification sets hold only no-caller handlers, so a write handler mis-filed as a read reddens it too. E2E: one whole-wire case in forge_e2e_pgtest_test.go over the newForgeE2EWire scaffold (forge_e2e_pgtest_test.go:84-115) proving the rejection shape end to end against real Postgres.

T3 [compass-server] — serve assembly: flag, seed, warn

Section titled “T3 [compass-server] — serve assembly: flag, seed, warn”

Interfaces:

// ForgeConfig (serve.go:108) gains:
// EnforceScopes bool // Beta: true; absent/false = Dogfood defer
// ScopeGrants []string // "handle=provider:host/repo", repo may be "*"
// buildForgeWriteService (serve.go:929) passes cfg.Forge.EnforceScopes to
// newForgeService and reconciles ScopeGrants before returning:
func reconcileForgeScopeSeed(ctx context.Context, st *store.Store, grants []string) error

Seed semantics mirror reconcileForgeSeed (serve.go:1006-1018): bootstrap-only GrantForgeScope per entry, handle resolved to account_id via the store, bad entry fails startup. Warn on enforcement-on + empty grant set (A4). CLI flags/env plumbed wherever SeedRepos/Poll already are.

Tests: config-parse + seed-reconcile unit tests beside serve_forge_test.go; a pgtest reconcile case beside serve_forge_pgtest_test.go:436-459’s pattern.

  • T1 — account_forge_scopes migration + Grant/Revoke/HasForgeScope store methods + pgtest suite.
  • T2 — requireForgeScope gate in the five write arms (post-dedup on creates) + forgeStore extension + fake + default-lane, exhaustiveness, and e2e tests + header-comment update.
  • T3 — ForgeConfig.EnforceScopes/ScopeGrants + seed reconcile + warn + assembly wiring + tests.

Proposed row (id assigned by the driver at PR assembly; true max observed on this base is DL-241, docs/designs/DECISIONS.md, so this takes the next free id ≥ DL-242), Comms & tools section:

ID Decision Status Record
DL-24x Forge-write scope enforcement (the deferred A8) is a server-side per-account allowlist: a new account_forge_scopes table (agent-or-owning-user grant, exact repo or '*' per coordinate, provider-aware case fold applied identically at grant and check, seeded declaratively via ForgeConfig.ScopeGrants + owner grant methods, never agent-self-granted) checked by requireForgeScope in every write arm of ExecuteForgeCallAsAccount — after coordinate resolution, and on the create arms after the F3 idempotency-memo check so a memo-hit retry (which writes nothing) is never rejected — before any provider call, rejecting out-of-scope targets as the in-band ForgeCallError{code:"not_found"} byte-identical to the provider-403/404 flatten text (the comms not-found/forbidden merge; never a Connect error), guarded against future ungated arms by a write-arm exhaustiveness test over the oneof descriptors, gated by ForgeConfig.EnforceScopes — false for Dogfood (single trust domain, today’s posture preserved), MANDATORY true for Beta regardless of the RIG-2682 account model; the flag’s DEFAULT direction (fail-open vs fail-closed) is OQ-1, deferred to Matt Proposed forge scope enforcement §Approach

Ledger-impact: adds one row (Comms & tools); refines the A8 no-scope posture DL-200 inherited (go/server/forge.go:16-18, tracing to the board-path “Resolved decision 2” single-trust-domain ruling — the posture lives in the implementing comment, not the DL-200 row text) without superseding DL-200 (the ForgeCaller seam shape stands); edits no existing row.

  • OQ-1 (load-bearing, DEFERRED TO MATT): enforcement default — fail open or fail closed? §A4 drafts EnforceScopes default false, so on a Beta deployment a misconfiguration (the flag simply unset) fails OPEN: scope enforcement silently off on the exact tier where Matt ruled it mandatory and unconditional. (a) Keep default-false: zero behavior change for every existing deployment (the all-optional ForgeConfig posture, go/server/serve.go:105-107), but Beta safety hangs on one remembered config bit. (b) Default fail-CLOSED with an explicit DisableScopeEnforcement Dogfood opt-out: Beta-safe by default, but every existing deployment must set the opt-out at upgrade or every forge write starts rejecting. Author’s lean, explicitly NOT a decision: (b) — a security control whose zero value means “off” invites exactly the silent-open misconfig the ruling exists to prevent, and the cost is one config line per Dogfood deployment versus a silent authz hole on Beta. Matt rules at freeze; this record does not resolve it.
  • OQ-2 (load-bearing): grant surface for Beta operators. MVP ships the declarative config seed + store methods only — no public RPC. Is that enough for Beta, or does Beta need a GrantForgeScope admin RPC/tool at launch? Recommendation: config-seed-only for this slice (no-human-clicks is satisfied by config-as-code; an RPC is additive later); file the RPC as a follow-up issue.
  • OQ-3 (load-bearing): grant granularity. Designed: grants attach to the owning user and cover the whole fleet (agent-or-owner check), per the standing “an agent acts within its owner’s space” ruling (go/internal/store/authz.go:80-82); schema admits per-agent rows additively. Confirm Matt wants owner-level MVP rather than per-agent-required. Recommendation: owner-level MVP.
  • OQ-4 (non-load-bearing): read arms. Reads stay ungated this slice (their signatures carry no caller, go/server/forge.go:472-485, and the ruling targets writes). Recommendation: accept; file a follow-up for read-side scope parity when tracked-read privacy matters (multi-tenant).
  • OQ-5 (non-load-bearing): wildcard grammar. repo = '*' grants a whole coordinate; no owner-prefix wildcards (owner/*) in MVP. Recommendation: accept — prefix wildcards are additive (repo LIKE variant) and unneeded at Beta’s grant volume.

The draft’s former rejection-text question (whether the refusal message may differ from existing not_found texts) is resolved in-design, not open: the out-of-scope refusal is byte-identical to the provider-403/404 flatten text "forge: artifact not found" (go/server/forge.go:627-628) — see §A3.