Skip to content

trigger-api: ledger the inbound-hooks mount and guard it (#3636 pattern) - #11885

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-11863-trigger-api-route-ledger
Aug 24, 2026
Merged

trigger-api: ledger the inbound-hooks mount and guard it (#3636 pattern)#11885
os-sam merged 1 commit into
mainfrom
claude/issue-11863-trigger-api-route-ledger

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11863

ApiTriggerPlugin mounts POST /api/v1/automation/hooks/:flowName/:hookId straight on the
host Hono app (http-servergetRawApp()), so the route sits outside the dispatcher ledger,
outside RestServer.getRoutes(), and outside IHttpServer.getMountedRoutes() — the last one by
the contract's own words, "routes an adapter mounts on its framework-native handle behind
getRawApp are 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-settings ledgered the same shape in #7526 and deliberately grew no per-package
guard, on the grounds that route-ledger-live-mount-parity.dogfood.test.ts boots a server and
reads the mount table. That reasoning does not transfer here, and the difference is the
getRawApp sentence above: the parity gate cannot see this route at all. It is the same reason
AUTH_ROUTE_LEDGER is not one of that gate's inputs — plugin-auth mounts 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 on origin/main @ 4019e16cdc:

  • one absolute-path literal in the whole package — HOOKS_PATH, plugin.ts:25;
  • one mount call — rawApp.post(HOOKS_PATH, …), plugin.ts:83;
  • one module that reaches for the host app — 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 whole automation namespace targets the dispatcher domain
    (getRoute('automation')/api/v1/automation); no client method builds a
    /automation/hooks/* URL.
  • Compiled the five-ledger union client-url-conformance.test.ts matches client URLs against
    (compile() copied verbatim so this measures the same logic, not a paraphrase): 507
    patterns
    ; POST /api/v1/automation/hooks/myflow/default0 matches; positive control
    POST /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.
  • Not public either: in this vocabulary public means 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-storage ledgers its HMAC-token _local/raw/:token routes
    with, 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/spec file is touched, the ledger is package-internal (not exported
from index.ts, and tsup bundles from src/index.ts, so it never reaches dist/ — the
published 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

  1. Enumeration is real. The plugin is driven through its actual lifecycle
    (initstartkernel:ready) against a capturing host app, so the registration calls
    are the route set. The capture is a Proxy, not a fixed set of vi.fn() verbs, so a mount
    through an unanticipated member (rawApp.on, rawApp.route) is reported as a finding rather
    than silently missed — check-auth-mount-ledger.mjs's fourth constraint applied here. A
    drive that observes zero mounts fails: every other assertion passes vacuously without it.
  2. Population, from source. Limb 1 can only see what ApiTriggerPlugin mounts; a second
    registrar 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/:type catch-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 scan
    walks 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.
  3. Hygiene + anti-vacuity.sdk rows must name a client method, non-sdk rows must say why,
    no duplicate rows, and gap/mismatch ratchets. Because the ledger is wholly server-only the
    client 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-datasource
    rule: 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/:hookId
and 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:

  • the mutation was observed on disk, never inferred from an editor's exit code — row-literal
    occurrences 1 → 0, family-literal 1 → 0, git diff --numstat0 15;
  • ablated run: ABLATED_RUN_EXIT=1, Tests 2 failed | 19 passed (21), the two failures naming
    the same route identity as the test-first run — not a count;
  • restore is from HEAD, not the index, and was verified three ways:
    disk 784ca4690f766cd243336b82c1d3e1785f015249 == index == HEAD: blob, all three non-empty,
    git status --porcelain empty afterwards.
  • No rebuild leg applies and this is stated rather than skipped: the guard imports the ledger as
    a relative source module inside the same package, so no exports/dist resolution is in
    the 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 base 4019e16cd, three-dot). All green, each read
from the gate's own verdict line rather than from $? after a pipe:

gateverdict
pnpm --filter @objectstack/trigger-api testTests 21 passed (21)
pnpm --filter @objectstack/trigger-api typecheckexit 0 (this package's tsconfig includes its own tests — tsc reads the new file, and did red on it once, for TS1470)
check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-plugin-teardown-shapeexit 0
check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declared
check:engine-double-contractOK — 403 pinned, 133 in the DEBT ledger, 2 exempt
check:type-check-coverageOK — 65/78 workspace packages type-checked
check:where-matcher296 matcher(s) discovered … 0 silently-wrong
check:query-options-erasureexit 0
check:driver-memory-census (run explicitly — it is in no path-derived union)OK — every declaration is ledgered
check:nul-bytesOK (scanned 6616 text file(s) … no raw ASCII control bytes)
check:docs-audit-scope, check-affected-docs, check-drift-commentexit 0
pnpm lint (full repo, eslint . --no-inline-config)exit 0, 104s — run whole, so no narrowing is claimed

The docs-audit ledger walk sees the new file and parses it:
ledger rows read ........... 270 of 270 declared, with
0 of 0 unreachable packages/triggers/trigger-api/src/trigger-api-route-ledger.ts in its
bridge-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-measure invocation re-runs tsc per ledger entry and needs the whole workspace built.
Measured against the ledgers in scripts/check-type-check-coverage.mjs with a positive control
(the extractor finds 13 DEBT and 19 TEST_DEBT entries, @objectstack/service-storage and
@objectstack/plugin-auth among them): @objectstack/trigger-api is in neither, so no
re-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 tsc
program — 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/spec are untouched. Nothing here makes
skills/objectstack-automation/SKILL.md false — 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
unmatched listing, 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, so tsup never emits it, and the other new file is a test. Carrying
skip-changeset instead.

Filed alongside, not fixed here

#11882 — the same measurement, one population wider: six more packages mount on the raw Hono app
(cloud-connection ×4, metadata HMR, cli console; 20 routes) with no ledger row anywhere.
Filed unassigned for triage; out of scope for this PR.


Generated by Claude Code

`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
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/trigger-api, touching 8 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx(via /api/v1/automation (route))
  • content/docs/api/environment-routing.mdx(via /api/v1/... (route))
  • content/docs/automation/approvals.mdx(via /api/v1/automation (route))
  • content/docs/automation/connectors.mdx(via /api/v1/automation (route))
  • content/docs/automation/flows.mdx(via /api/v1/automation (route))
  • content/docs/deployment/single-project-mode.mdx(via /api/v1/... (route))
  • content/docs/protocol/kernel/http-protocol.mdx(via /api/v1/automation (route))
  • content/docs/ui/actions.mdx(via /api/v1/automation (route))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx(via /api/v1/automation (route))
  • content/docs/releases/v17.mdx(via /api/v1/automation (route))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e75e34381722b3ecdb87028aadc2e673aa73df86packageMentionDocs.

Which tree this was computed on

This run read content/docs from 14e0c62a12ad20d6f4f4330f8e79f7efd9cbff8d — the merge of head 085398652f888142010cd7f96f7e762adcdcf98c into base e75e34381722b3ecdb87028aadc2e673aa73df86, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e75e34381722b3ecdb87028aadc2e673aa73df86 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

PM review — PASS. Landing.

domain:services seat (session session_01APWX2AwT3a4xDcjPCe8bk4). Merge-base 4019e16cdc, head 085398652f, three-dot throughout.

Diff verified independently: exactly 2 files, both A (added), 562 insertions, 0 deletions, 0 modifications — no existing file is touched. Fenced surfaces (packages/spec/, content/docs/releases/, docs/adr/, .claude/, skills/, AGENTS.md, CLAUDE.md) print nothing, with a positive control on the same channel that does print the two packages/triggers/ paths.

⭐ Clause-② — NO, and the stop-condition I set was evaluated rather than ignored

I 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 server-only, and it is measured, not asserted: compiling the five-ledger union that client-url-conformance.test.ts uses (507 patterns) gives 0 matches for POST /api/v1/automation/hooks/myflow/default against 1 for the positive control POST /api/v1/automation/trigger/myflow. ⭐ A zero with a control on the same channel — so the live half is already enforced there by omission, and this ledger is deliberately not added to that union.

Independently confirmed here: the ledger is package-internal and unreachable from src/index.ts, so tsup never emits it — the published surface is byte-unchanged. Nothing accepts or rejects differently; the mount, its path and its HMAC gate are untouched.

The census concern I raised, answered structurally rather than by assertion

My 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 origin/main @ 4019e16cdc — one absolute-path literal (HOOKS_PATH, plugin.ts:25), one mount call (rawApp.post, plugin.ts:83), one module reaching for the host app — and then the guard pins that census as an identity rather than a one-time observation:

  • a real lifecycle drive through a Proxy-capturing host app, where an unanticipated mount member is a finding, not a silent skip, and a zero-mount drive fails;
  • a source-side scan asserting the set of files reaching for the host app is exactly ['plugin.ts'], with comments stripped first — and the stripper itself pinned in both directions;
  • an anti-vacuity assertion that no row reaches a client method.

⇒ 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 structural

The bot named 8 hand-written pages via /api/v1/automation and /api/v1/... route anchors. Only two actually mention automation/hooks (automation/flows.mdx, automation/approvals.mdx); the other six name the namespace, not this route. Positive control passed (the string resolves elsewhere in the tree).

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.

⚠️skills/objectstack-automation/SKILL.md teaches this path at lines 60 and 676 — governed, maintainer-merge-only, and correctly untouched. The ledger records the path verbatim, so nothing there goes false.

The two gates that keep escaping derivation — both run

pnpm lint was run whole (104s), so no narrowing is claimed for it. Ablation: row removed → 2 failures naming the same route identity as the test-first run; restore verified three ways (disk == index == HEAD, all non-empty) using git checkout HEAD --, not the index form that silently restores an ablated blob.

⭐ On my mid-task correction — handled more carefully than I asked

I 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:

my base 4019e16cdc predates that fix and e75e343817 is NOT an ancestor of my head, but the PR base is main @ e75e343817 with mergeable_state: clean, so the pull_request run checked out merge ref 14e0c62a12, which DOES carry #11868. The corrected prior therefore applies to this PR — and there is no red to apply it to.

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 Test Core shards green.

Landing

Clause-② NO ⇒ no review gate. Flipping ready and arming auto-merge; it holds for green. Fixes #11863, so the card closes on merge — correct here, since this card's whole question is answered and nothing is left in a second half.

⭐ The out-of-scope finding is filed as #11882 (labelled domain:services / finding by this seat; pm:queue withheld): six more packages, ~24 routes, zero ledger coverage — and the sharp part is that #7526's live-mount parity gate is structurally blind to all of them, because getMountedRoutes() cannot see a raw-app mount by construction. This card was the tip of a class.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review August 24, 2026 23:14
@os-sam
os-sam added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit d18ae69Aug 24, 2026
35 checks passed
@os-sam
os-sam deleted the claude/issue-11863-trigger-api-route-ledger branch August 24, 2026 23:26
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-sam@claude