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).
Problem / Intent
Section titled “Problem / Intent”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.
Global Constraints
Section titled “Global Constraints”- Go,
go/module; the chokepoint ispackage server(go/server/forge.go). - Rejection is in-band, never a Connect error: a tool-level refusal rides
the
ForgeCallResult_Errorarm 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) andforgeErrorResult(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-identicalnot_found(go/server/forge.go:602-604). - Store access from the chokepoint goes through the narrow
forgeStoreinterface (go/server/forge.go:140-144) so the ordering is provable againstfakeForgeStorein 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, FKON DELETE RESTRICT, coordinate columns aligned to the 0013 convention (SMALLINT provider CHECKIN (1,2,3,4)+forge_hostin every key,0001_init.sql:604-608). - Ledger: this record proposes its DL row below; the driver assembles the
final id into
DECISIONS.mdat PR-assembly time. Do not editDECISIONS.mdfrom this record. - Red → green: every task lands its failing test first.
Approach
Section titled “Approach”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):
- Boot seed (MVP, required):
ForgeConfiggrowsScopeGrants []stringofhandle=provider:host/repoentries (repo*allowed), reconciled at serve assembly exactly asSeedReposreconciles intoforge_repo_subscriptions— “bootstrap-only insert, ON CONFLICT DO NOTHING” (go/server/serve.go:1006-1008,go/internal/store/forge_cursors.go:136-148is the pattern). The deployment’s scope set lives in config, deployed by merge to main. - Owner grant RPC (same slice, small):
GrantForgeScope/RevokeForgeScopestore methods, exposed later on the admin surface; in this slice they exist for the seed reconciler, SQL-parity operators, and tests (theSetForgeRepoSubscriptionEnabledposture,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-repoblast 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):resolveTarget→dedup→ (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 atforge.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):resolveTarget→requireForgeScope→ 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 afterresolveTarget.
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 scope →
forgeErr(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 atforge.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 fault →
storeForgeError(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.
A4 — the Dogfood/Beta tier switch
Section titled “A4 — the Dogfood/Beta tier switch”Enforcement is a serve-config bit, not a build variant:
ForgeConfig.EnforceScopes bool(besideSeedRepos/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 intoforgeService(a newenforceScopes boolfield besidenow,go/server/forge.go:151-156).- When
EnforceScopesis true andScopeGrantsis empty and the table is empty, startup logs a Warn (thewarnPartialForgeWriteSecretsposture,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.
Alternatives considered
Section titled “Alternatives considered”- 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
reposails through (forge.go:16-18records 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_subscriptionsas 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
resolveWriteTargethelper (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 afterresolveTarget(§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
PermissionDeniedinstead 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.ForgeCallErrorWire 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) errorSeed 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_scopesmigration +Grant/Revoke/HasForgeScopestore methods + pgtest suite. - T2 —
requireForgeScopegate in the five write arms (post-dedup on creates) +forgeStoreextension + fake + default-lane, exhaustiveness, and e2e tests + header-comment update. - T3 —
ForgeConfig.EnforceScopes/ScopeGrants+ seed reconcile + warn + assembly wiring + tests.
Ledger delta
Section titled “Ledger delta”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.
Open Questions
Section titled “Open Questions”- OQ-1 (load-bearing, DEFERRED TO MATT): enforcement default — fail open
or fail closed? §A4 drafts
EnforceScopesdefault 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 explicitDisableScopeEnforcementDogfood 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
GrantForgeScopeadmin 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 LIKEvariant) 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.