Uh oh!
There was an error while loading. Please reload this page.
trigger-api: ledger the inbound-hooks mount and guard it (#3636 pattern) - #11885
Conversation
`ApiTriggerPlugin` mounts `POST /api/v1/automation/hooks/:flowName/:hookId` straight on the host Hono app via `getRawApp()`, so the route is outside the dispatcher ledger, outside `RestServer.getRoutes()`, and outside `IHttpServer.getMountedRoutes()` by the contract's own words -- which is why the #7526 live-mount parity gate cannot see it either. It carried no reviewed disposition anywhere. Adds the per-package ledger in the #3636 pattern (one row, `server-only`, measured) and a conformance guard with three limbs: a real lifecycle drive through a capturing host app, a source-side population scan so a second registrar cannot hide behind a one-row census, and hygiene + anti-vacuity assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 14e0c62a12ad20d6f4f4330f8e79f7efd9cbff8d && git checkout 14e0c62a12ad20d6f4f4330f8e79f7efd9cbff8d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e75e34381722b3ecdb87028aadc2e673aa73df86 085398652f888142010cd7f96f7e762adcdcf98c && git checkout -B drift-repro e75e34381722b3ecdb87028aadc2e673aa73df86 && git merge --no-ff 085398652f888142010cd7f96f7e762adcdcf98c
node scripts/docs-audit/affected-docs.mjs --json e75e34381722b3ecdb87028aadc2e673aa73df86
|
os-sam
commented
Aug 24, 2026
PM review — PASS. Landing.
Diff verified independently: exactly 2 files, both ⭐ Clause-② — NO, and the stop-condition I set was evaluated rather than ignoredI dispatched this with Clause-② NO plus a named stop-condition: if the measured disposition reached the SDK or generated a client method, stop and report — that would be a public-surface widening. The implementer measured it the other way and says so explicitly, which is the part that matters: a stop-condition reported as "did not trigger" is worth more than one silently unmentioned. The disposition is Independently confirmed here: the ledger is package-internal and unreachable from The census concern I raised, answered structurally rather than by assertionMy brief warned that "a one-row ledger that misses a second mount is worse than no ledger, because it reads as a completed census." The population was re-derived on
⇒ A second mount appearing later reds this guard instead of quietly widening a "complete" ledger. That is the difference between a census and a snapshot. docs-drift — no prose goes false, and the reason is structuralThe bot named 8 hand-written pages via But the check that settles it is the diff, not the grep: this PR adds two files and modifies none. The route's path, method, mount and behaviour are byte-unchanged. ⇒ Prose describing that behaviour cannot be falsified by a declaration about it that changes nothing. All 8 rows are true-by-anchor, false-by-relevance. ⛔ The 2 release-owned pages were listed read-only and are untouched.
The two gates that keep escaping derivation — both run
⭐ On my mid-task correction — handled more carefully than I askedI corrected hazard #4 mid-flight (#11808 closed by #11868). The implementer did not simply accept it; it worked out which tree CI actually ran on:
That is the same distinction the docs-drift bot's own "which tree this was computed on" section exists to teach, applied unprompted to a different question. CI read once after the fact: 34 checks, 29 success, 5 skipped, 0 non-green, all six LandingClause-② NO ⇒ no review gate. Flipping ready and arming auto-merge; it holds for green. ⭐ The out-of-scope finding is filed as #11882 (labelled Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11863
ApiTriggerPluginmountsPOST /api/v1/automation/hooks/:flowName/:hookIdstraight on thehost Hono app (
http-server→getRawApp()), so the route sits outside the dispatcher ledger,outside
RestServer.getRoutes(), and outsideIHttpServer.getMountedRoutes()— the last one bythe contract's own words, "routes an adapter mounts on its framework-native handle behind
getRawAppare outside this table by construction" (packages/spec/src/contracts/http-server.ts).It carried a reviewed disposition nowhere.
This adds the per-package ledger in the #3636 pattern plus its guard. Two files, both new, both
inside
packages/triggers/trigger-api/src/; no existing file is touched.Why a per-package conformance test, and not the parity gate
service-settingsledgered the same shape in #7526 and deliberately grew no per-packageguard, on the grounds that
route-ledger-live-mount-parity.dogfood.test.tsboots a server andreads the mount table. That reasoning does not transfer here, and the difference is the
getRawAppsentence above: the parity gate cannot see this route at all. It is the same reasonAUTH_ROUTE_LEDGERis not one of that gate's inputs —plugin-authmounts on the raw app too.So the #3636 shape is the right one, and the header says so rather than leaving the next reader
to re-derive it.
Population, re-derived rather than inherited
The card named one row as of
a6eca9223. Re-measured onorigin/main@4019e16cdc:HOOKS_PATH,plugin.ts:25;rawApp.post(HOOKS_PATH, …),plugin.ts:83;plugin.ts.So the ledger is one row, and the guard's second limb pins that census as an identity rather
than leaving it as a one-time observation (below).
Disposition:
server-only, measured@objectstack/client's wholeautomationnamespace targets the dispatcher domain(
getRoute('automation')→/api/v1/automation); no client method builds a/automation/hooks/*URL.client-url-conformance.test.tsmatches client URLs against(
compile()copied verbatim so this measures the same logic, not a paraphrase): 507patterns;
POST /api/v1/automation/hooks/myflow/default→ 0 matches; positive controlPOST /api/v1/automation/trigger/myflow→ 1 match (dispatcher POST /automation/trigger/:name).So the live half of "the SDK does not reach this route" is already enforced next door by
omission, and this ledger is deliberately not added to that union — adding it would
remove exactly that protection.
publiceither: in this vocabularypublicmeans an anonymous browser surface(public forms, share-link resolution). This is machine-to-machine, gated by the flow's own
HMAC secret — the shape
service-storageledgers its HMAC-token_local/raw/:tokenrouteswith,
server-only.Clause ②, assessed independently against the live text in
scripts/pm/dispatch-gates.mjs:NO. The clause is "changes contract accept/reject behaviour or widens the public surface".
This declares a route that already exists and already serves; nothing about what it accepts or
rejects moves, no
packages/specfile is touched, the ledger is package-internal (not exportedfrom
index.ts, andtsupbundles fromsrc/index.ts, so it never reachesdist/— thepublished surface is byte-unchanged), and the disposition chosen grants the SDK nothing. The
dispatch seat's stop-condition — stop if the measurement lands on an SDK disposition — did not
trigger; the measurement landed the other way.
The guard, three limbs
(
init→start→kernel:ready) against a capturing host app, so the registration callsare the route set. The capture is a
Proxy, not a fixed set ofvi.fn()verbs, so a mountthrough an unanticipated member (
rawApp.on,rawApp.route) is reported as a finding ratherthan silently missed —
check-auth-mount-ledger.mjs's fourth constraint applied here. Adrive that observes zero mounts fails: every other assertion passes vacuously without it.
ApiTriggerPluginmounts; a secondregistrar added later would be invisible to it, and a one-row ledger that misses a second
mount reads as a completed census. So the package's own non-test source is scanned: every
absolute-path literal must be ledgered, and the set of files reaching for the host app must
be exactly
['plugin.ts']. The literal scan is deliberately broad rather than/api/-anchored—
/.well-known/objectstack(Three ledgered /meta routes are never mounted and die in the/meta/:typecatch-all — the route audit can't see this class because it treats the ledger as ground truth for what's mounted #7526) was a route at the site root, which an anchored scanwalks straight past. Comments are stripped first (prose cannot mount a route) and the
stripper is itself pinned in both directions, because a stripper that swallowed live code
would make the census read clean while measuring nothing.
sdkrows must name a client method, non-sdkrows must say why,no duplicate rows, and gap/mismatch ratchets. Because the ledger is wholly
server-onlytheclient half would hold vacuously, so the audit's actual finding — no row reaches a client
method — is asserted, with the reason written next to it. That is the
service-datasourcerule: a guard that can only ever pass is the "declared but unverified" shape these ledgers
exist to remove.
Verification
All results below are from the tree at
085398652f, the final commit on this branch.Test-first, signature predicted in writing before the first run. Predicted: with an empty
ledger, exactly two assertions red, both naming the route by identity, everything else green.
Observed, first run:
Tests 2 failed | 18 passed (20)—routes with no trigger-api-route-ledger entry: POST /api/v1/automation/hooks/:flowName/:hookIdand
absolute-path literals in trigger-api source with no ledger row: src/plugin.ts: /api/v1/automation/hooks/:flowName/:hookId.Row added →
Tests 21 passed (21).Ablation, on the committed tree, under an unconditional
trap … EXIT INT TERM:occurrences
1 → 0, family-literal1 → 0,git diff --numstat0 15;ABLATED_RUN_EXIT=1,Tests 2 failed | 19 passed (21), the two failures namingthe same route identity as the test-first run — not a count;
HEAD, not the index, and was verified three ways:disk
784ca4690f766cd243336b82c1d3e1785f015249== index ==HEAD:blob, all three non-empty,git status --porcelainempty afterwards.a relative source module inside the same package, so no
exports/distresolution is inthe path — which the red→green→red transitions on source edits demonstrate directly.
Gates. The family was derived, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(2 paths vs merge base4019e16cd, three-dot). All green, each readfrom the gate's own verdict line rather than from
$?after a pipe:pnpm --filter @objectstack/trigger-api testTests 21 passed (21)pnpm --filter @objectstack/trigger-api typecheckTS1470)check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-plugin-teardown-shapecheck:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declaredcheck:engine-double-contractOK — 403 pinned, 133 in the DEBT ledger, 2 exemptcheck:type-check-coverageOK — 65/78 workspace packages type-checkedcheck:where-matcher296 matcher(s) discovered … 0 silently-wrongcheck:query-options-erasurecheck:driver-memory-census(run explicitly — it is in no path-derived union)OK — every declaration is ledgeredcheck:nul-bytesOK (scanned 6616 text file(s) … no raw ASCII control bytes)check:docs-audit-scope,check-affected-docs,check-drift-commentpnpm lint(full repo,eslint . --no-inline-config)The docs-audit ledger walk sees the new file and parses it:
ledger rows read ........... 270 of 270 declared, with0 of 0 unreachable packages/triggers/trigger-api/src/trigger-api-route-ledger.tsin itsbridge-coverage table. A row the recogniser declined would have shown as a partial read.
check:type-check-debt— narrowing declared, and measured before declaring. Its--re-measureinvocation re-runstscper ledger entry and needs the whole workspace built.Measured against the ledgers in
scripts/check-type-check-coverage.mjswith a positive control(the extractor finds 13
DEBTand 19TEST_DEBTentries,@objectstack/service-storageand@objectstack/plugin-authamong them):@objectstack/trigger-apiis in neither, so nore-measured count in this gate can move from this diff. The structural half —
check:type-check-coverage, which is what fails when a new test file sits outside every tscprogram — was run and is green above. CI runs the ratchet regardless.
Governed surfaces
skills/**,docs/adr/**,.claude/**,AGENTS.md,CLAUDE.md,content/docs/releases/**and
packages/specare untouched. Nothing here makesskills/objectstack-automation/SKILL.mdfalse — it teaches this exact path at lines 60 and 676,the path is unchanged, and the ledger records it verbatim.
#11050's detector is untouched. Once this lands, the route stops appearing in that detector's
unmatchedlisting, which is a consequence rather than a change to it.No changeset
Nothing user-visible ships: the ledger is package-internal and unreachable from
src/index.ts, sotsupnever emits it, and the other new file is a test. Carryingskip-changesetinstead.Filed alongside, not fixed here
#11882 — the same measurement, one population wider: six more packages mount on the raw Hono app
(
cloud-connection×4,metadataHMR,cliconsole; 20 routes) with no ledger row anywhere.Filed unassigned for triage; out of scope for this PR.
Generated by Claude Code