ADR-0049: Add-Only Endpoint Scope Widening — One More Lane Should Not Cost a Re-Vend
Status · proposed · Date · 2026-10-21 · Deciders · Proposed by joestump-agent, pending Joe
Status note: this ADR is proposed. It ships with an implementation so the choice can be argued against working code rather than prose; nothing here is settled until Joe accepts it. The requirement text it implies for SPEC-0006 and SPEC-0007 is listed under Consequences rather than applied: accepted records stay as accepted until this ADR is accepted.
Context and Problem Statement
An endpoint's scope is fixed at vend (ADR-0008), and the only way to change it has been
switchboard endpoint revoke followed by a fresh endpoint vend. For a change that removes something
that is defensible: the credential is what the grant is computed from, so dropping a queue or a verb
should be a decision with a reveal attached.
For a change that only adds, re-vending is worse than the change it is meant to make. A webhook belongs
to the endpoint that created it, so a re-vend mints a new ingest URL and a new signing secret, and the
producer — a repository's hook configuration, a form's automation, a partner's relay — has to be edited
to point at them. Meanwhile the old endpoint's todo history, its in-flight claims and its routes go with
it. Issue #590 is precisely that report: a lane that needed one more queue in its webhook_queues got a
403 not_granted on the delivery, and the documented answer was to tear the endpoint down and vend it
again.
The asymmetry is worth naming exactly. A widening cannot break a route that works today: every additive change leaves the union that routing already computes strictly larger. A narrowing can. So the gap is not "make scope editable" but "give the safe half of scope editing a path".
Decision Drivers
- The credential must not change. A widen that rotated the token would move the problem from the producer's hook configuration into the lane's env file, which is the failure this exists to remove.
- Additive must be structural, not a matter of passing the right flag. If a request body can express a removal, then eventually a removal happens through it.
- An authorization change that lands silently is the thing a reviewer is trying to prevent. Every field that moves needs a record, and the record cannot be allowed to drift from the change.
- Ownership and tenant isolation cannot loosen on the way: the same reach rules as every other
human-facing endpoint write (ADR-0022),
state = 'active'in the predicate. - One command with the confirm-before-write shape the rest of the CLI already uses.
Considered Options
- (A) Re-vend, documented harder. Add a guide section walking the revoke/vend/re-point dance. Costs the producer a config edit and loses the endpoint's history, which is the part operators actually miss.
- (B) General scope PATCH. Let
PATCH /api/v1/endpoints/{ref}take scope fields, additively or not. One surface for everything, but it puts a silent narrowing — a verb-list rewrite — behind the same call that edits a lease TTL. - (C) Add-only widen. A dedicated route and command that can only union arrays and raise the webhook ceiling, refuses any key that could remove, records the before and after of every field it moves in the same transaction, and never touches the credential.
Decision Outcome
Chosen: (C), add-only widen.
POST /api/v1/endpoints/{ref}/scope/widenacceptsadd_webhook_queues,add_webhook_source_types,add_scope_verbsandwebhook_max(a ceiling to raise, never lower). Any other key — including the barewebhook_queuesa caller might guess — is refused with 400 by a decoder that rejects unknown fields, so "replace this list" has no spelling.- Without
confirm: truethe call is a preview: it reportsbefore,after, which fields would move, andrequires_confirmation: true, and writes nothing. Withconfirm: trueit applies. The CLI is the same two calls — preview, print the diff, one[y/N]prompt, confirm — and--yesskips the prompt for scripts. - Widening
webhook_queuesneeds no routing change. The grant answering "may this endpoint receive on queue Q" is already a union computed over the owner's active endpoints, so the widened value is honored on the next read. That is the whole mechanism's claim to safety, and the regression test asserts the reported 403 through the routing decision rather than against a row. webhook_maxrefuses a decrease withErrScopeNarrowing→ 400. Raising a ceiling is additive; lowering one strands webhooks that already exist.- Re-adding a value the endpoint already has is a successful no-op that writes no audit row: idempotent scripts stay quiet and the trail stays a record of changes.
GET /api/v1/endpoints/{ref}/scope/wideningsreturns the trail newest-first — field, before, after, timestamp — for an endpoint the caller owns, withswitchboard endpoint scope history SLUGas its command, so SPEC-0035's CLI parity holds.switchboard endpoint scope widen SLUG|ID --add-queue a,b [--add-source-type …] [--add-verb …] [--webhook-max N] [--yes] [--json], withswitchboard endpoint scope add-queue SLUG|ID QUEUEas the one-flag form for the case the report describes.
Mechanics
internal/db/migrations/0034_endpoint_scope_widenings.sqladdsendpoint_scope_widenings(endpoint_id, actor_human_id, field, before, after, created_at), withfieldconstrained to the four widen-able fields andendpoint_idcascading on delete. There was no table to put it in: a revoke's audit trail is the terminal endpoint row, andeventsis ingest data, so an authorization delta needed its own.internal/store/endpoint_scope.golocks the row withSELECT … FOR UPDATEunder the ownership andstate = 'active'predicate, unions the arrays, applies oneUPDATE, and inserts one audit row per field that actually moved — all in one transaction, so a change and its record cannot disagree. Naming nothing to add, or only values the endpoint already has, returnsErrNothingToWidenbefore any write. The reservedquarantinequeue is refused, as everywhere else a queue name enters the system.- Verbs are resolved with the same resolver the vend path and the consent screen use (ADR-0023, ADR-0047), so a widen cannot name a verb the vend path would reject, and notify-hook verbs (ADR-0029) stay name-only there. Ingest source types are validated against the server's own set.
- A widen reads no credential and writes none. The token that authenticates the endpoint before the call authenticates it afterwards, which the store test asserts through the credential lookup itself.
Consequences
- Not breaking. Nothing that works today changes, and no new flag is mandatory. The vend path, its defaults and the web wizard are untouched.
scope_queuesis deliberately out of scope. Those are the queues an endpoint may claim; adding one changes which work the lane can take, which is not an additive change to a route. A lane that needs a second queue to drain is a second endpoint, or a re-vend — on purpose.- The web UI is a follow-up, not part of this slice: the widen form and the vend wizard's copy want the same framing ("one more lane is a widen, not a re-vend"), and shipping the API and CLI first lets the shape be argued before the form is built. Until it exists, the CLI is how you widen.
- Widening is not a way to hand administration to a lane that never had it. A lane needs its verbs named at vend (ADR-0047); this is for the lane whose routes outgrew its grant.
- Implied requirement text, for SPEC-0006 "Webhook Self-Management Within a Vended Ceiling" and SPEC-0007 once this ADR is accepted: an operator MAY widen, and only widen, an active endpoint they own; a request that would remove a granted value MUST be refused; every field a widen moves MUST be recorded with its before and after in the same transaction as the change; and a widen MUST NOT alter the endpoint's credential.
Confirmation
internal/store/endpoint_scope_test.go pins the union, the reported 403 from the issue closing through
the routing grant, the idempotent no-op that writes no history row, the refusal of a narrowing
webhook_max, ownership and revoked-endpoint reach, and that the credential still resolves the same
endpoint afterwards; internal/server/api_endpoint_scope_test.go pins preview-then-confirm, that a second
widen keeps the first queue, the refusal matrix (a removal key, a replacement key, an empty body, an
unknown source type or verb, a negative and a lowering webhook_max), and 404/409 reach;
cmd/switchboard/endpoint_scope_test.go pins that declining the prompt calls the API once and writes
nothing, that --yes confirms, and that a usage error never reaches the API.