docs(adr): ADR-0110 — action identity is name; declaration-admission gate for executables (#3935) - #3949
Merged
Merged
Conversation
…mission gate for executables (#3935) Records the design behind the #3935 fix and the staged retirement of fail-open 'handler-only' dispatch: - D1 invocation identity is the declarative name on every surface; target stays a binding expression (polymorphic, interpolatable, non-unique) and is never an identity key - D2 resolve-then-address: declaration first, handler-key candidates derived from it (the MCP rotation becomes the shared order) - D3 resolution trichotomy: found -> gate+dispatch; metadata plane unreachable -> 503; genuinely undeclared -> refuse (staged warn-first, OS_ALLOW_UNDECLARED_ACTIONS escape hatch) - D4 declared-but-hidden is the blessed headless pattern - D5 registerAction<->declaration reconciliation lint + boot check (the ADR-0078 converse: no silently ungoverned executable) - D6 security-gate strictness is opt-out, never opt-in Empty-frontmatter changeset: docs-only, releases nothing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…ajor staging Protocol 17 is in RC, so the breaking window the staged plan was waiting for is the one already open. Staging would hold a known authorization hole open through an entire additional major, and park the gate in ADR-0049's prohibited 'parses but does not enforce' state meanwhile. v17 already lands two siblings of this inversion (trigger-user-less flow may not touch data; unreachable datasource fails the boot). - D3 refuses undeclared handlers in 17; OS_ALLOW_UNDECLARED_ACTIONS demoted to a migration valve slated for removal in 18 - D1 drops the transitional target-acceptance path entirely — objectui ships lockstep inside the same major, so the window is empty and the second namespace would be built only to delete it - D2-first preserved as a hard invariant: refusal must land on top of correct resolution, never before it - lint + boot inventory + v17 migration entry must ship in the same release, so a hard failure is diagnosable - staged-across-majors moved into Alternatives with the reasoning Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design record for #3935 and the fail-closed retirement of "handler-only" dispatch. Docs-only — no runtime change in this PR (empty-frontmatter changeset per the #3373 convention).
Targeted at protocol 17, which is in RC now (
@objectstack/spec@17.0.0-rc.0). Revised from the first draft, which staged the inversion across two majors — see Fail-closed in 17 below.Why an ADR and not just the fix
#3935's narrow fix (REST resolves declarations by
name, then rotates handler-key candidates the way MCP already does) needs no new decision — it implements contracts the spec and ADR-0066 D4 already state. What does need a decision is everything around it: the fail-opencatchindomains/actions.tscurrently reads "no declaration resolved" as "author wanted no gate", which collapses three states with opposite meanings (declared-but-missed, metadata-plane-unreachable, genuinely undeclared) into one ungated branch. Flipping that default is a breaking behavioral contract change, and in this repo those carry ADR numbers so the next "this catch is inconvenient" PR can't silently revert it.The finding that motivates it
The REST route's
:actionsegment does double duty — declaration lookup treats it as aname, handler dispatch treats it as a registry key. For a target-bound action those are different strings, so the two documented callers each work on exactly the half the other breaks:name)complete_tasktarget || name)completeTaskThe docs' claim that "the Console button and the API call run the same gate and handler" is currently false in both directions. Nobody noticed because each caller took the path that worked for it.
The six rulings (D1–D6)
nameon every surface;targetis a binding expression (polymorphic, interpolatable, non-unique) and never an identity key. No transitional target-acceptance path — objectui ships lockstep inside the same major, so that window is empty.OS_ALLOW_UNDECLARED_ACTIONS=1as a documented migration valve slated for removal in 18.registerActionwithout a declaration has no remaining legitimate use (server-internal logic is a plain function).registerAction↔ declaration reconciliation lint + boot inventory, in both directions — the mechanised converse of ADR-0078: 0078 outlaws declarations nothing executes (silently inert); 0110 outlaws executables nothing declares (silently ungoverned). Together: everything declared runs, everything that runs is declared.OS_ALLOW_*is the sanctioned shape for security escape hatches;OS_*_STRICT_ENABLEDstays reserved for non-security contracts (existing flags not renamed).Fail-closed in 17, not staged across 17→18
The first draft staged this (warn in 17, CI-only, refuse in 18). Rejected on timing: 17 is in RC, so the breaking window the staging was waiting for is the one already open, and waiting would hold a known authorization hole through an entire extra major — parked in exactly the "parses but does not enforce" state ADR-0049 prohibits. It also joins two siblings already in the v17 breaking set: "A flow run with no trigger user may not touch data" (#3760) and "A datasource that cannot connect fails the boot" (#3741/#3758/#3826). A third fail-closed inversion belongs in the same release.
What the staging was really protecting — never refusing a correctly declared action — is preserved directly as the D2-first invariant: refusal must land on top of correct resolution, never before it. Three things ship together in 17 or the refusal is undiagnosable: D2's rotation, the D5 lint (find orphans at build time, pre-upgrade), and the boot inventory (a hard failure with a checklist instead of a support ticket).
Lineage
Deliberately positioned as an extension of standing rulings, not a new invention: ADR-0049 (enforce-or-remove — a gate that parses but does nothing manufactures false compliance), ADR-0078 (the converse, see D5), ADR-0096 (missing identity must not fail open; this ADR applies the same posture to missing declaration), ADR-0109 (an undeclared action materialises no
action_<name>tool, so the AI surface already can't see it — REST is the one surface that lets it run anyway).Review focus
packages/platform-objects+examplesfinds no action declaring action-levelrequiredPermissionstogether with atarget, so the gate gap is latent rather than live today — but the refusal also hits genuinely undeclared handlers. Anyone aware of those in real apps beyond the examples?Closes nothing by itself; #3935 stays open to track the implementation.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1