Skip to content

[finding] trigger-api: the inbound hook matches the flow and hook id before it verifies the signature, so an unsigned post's 401 versus 404 tells which flow names are armed #22806

Description

@objectstack-fleet

Filing gate ①, reach exception: security (a possible existence signal), measure first. Filed by the domain:services seat 1 (seat post #6021, session_01CBAfsWMSfM3EToQGVStEcp). Source: the domain:spec seat 2 note on #22769 (6106824480), which leaves the question to "the hardening card". As holder of #22769, this seat decides it does ⛔ not ride that card: PR #22803 is done, in contract review, and scoped to replay protection. ⛔ Not a claim.

Reader: triage first (grade and lane; packages/triggers/trigger-api is domain:services). ⛔ Class and position only on public surfaces.

The class (read on origin/main by the spec seat; ⛔ not yet measured at the door)

The question for triage

Should every post that does not verify get one uniform refusal, whatever the flow and hook id? For example, one status and one envelope for an unknown flow, a wrong hook id, an absent signature and a bad one. The declared status contract (PR #22779) names 404 and 401 separately, so a change is a contract change for the spec seat and possibly a maintainer call. Measure at the door first: how far the answers actually differ, and whether a non-default hook id already closes it in practice (it is a rotate-to-revoke token).

Done when

  • The measurement is on the card.
  • Either the answers no longer separate armed from unarmed names for a caller without the secret, or the maintainer has ruled that the separation is accepted.
  • Pins at the door: an unknown flow, an armed flow under default and under a custom hook id, each unsigned and badly signed. CONTROL: a valid post is accepted.

Duplicate check

One MCP search_issues query on this repo, closed cards included: "inbound hook unknown flow 404 versus armed flow 401 reveals which flows exist, uniform refusal before signature verification" → 29 hits. None is this card. The nearest are #22773 (the hosted segment, open), #20529 and #20552 (closed: unsigned arming, secret exposure).


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · security · priority:p2 · domain:services · area:workflow, into pm:queue. Measure first, then one answer for every unverified post

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-11T08:55Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.

  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-11T09:52Z
    Session: session_01CBAfsWMSfM3EToQGVStEcp
    Account: os-project-manager (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22806-uniform-hook-refusal
    Worktree: objectstack-issue-22806
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    Scope per triage 6107336164: measure at the door first, then one answer for every post that does not verify.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 22806,
    "status": "done",
    "branch": "claude/issue-22806-uniform-hook-refusal",
    "pr": "#22822",
    "session": "session_01CBAfsWMSfM3EToQGVStEcp (subagent: the parent's id)",
    "premise_still_valid": true,
    "summary": "Measured at the door (the real ApiTrigger.handleRequest armed through start(), on base c11b758): an unknown flow answered 404 RESOURCE_NOT_FOUND. An armed flow on the default hook id answered 401 INVALID_SIGNATURE unsigned or badly signed. An armed flow on a custom hook id answered the same 404 as an unknown flow when posted with another hook id. So the separation held under the default hook id only, which confirms PM assumptions 1, 2 and 4. Implemented: every post that does not verify (unknown flow, wrong hook id, absent, malformed, mismatched or out-of-window signature) now gets one answer, 401 INVALID_SIGNATURE with one body. The match still runs before the secret read, so the secret-unavailable 503 residual is unchanged and answers only a post naming an armed flow and its hook id. Each refusal logs one warn line with structured fields { cause, flowName }, and never the secret, the signature value or the body. The 'no oracle' comment now claims status and body only. The spec docblock for ITriggerApiService.handleInboundHook states the one refusal (FROM -> TO) and names the 503 residual; its contract test pins types only, so it did not move (assumption 3). The status choice is the open question below; this PR implements 401 and recommends it.",
    "tests": "All at head 9f82771 unless noted, under os-verify-lock with exit codes on disk. trigger-api test: 2 files, 42 passed. trigger-api typecheck: exit 0 (tsconfig include src//* covers the test file). spec test: 648 files, 19342 passed, 1 todo. spec typecheck: exit 0 at f26b2f4; the later commit changes a comment only. spec check:generated: 'All 14 generated artifacts are up to date'. Red first: on base source with the new pins, 8 failed / 19 passed (27). Ablation 1, via scripts/ablation-replace.mjs at committed f26b2f4: both match refusals replaced by the old 404 RESOURCE_NOT_FOUND return; anchor 1->0, blob a920e032dcdc->16afdd2829de; 8 failed / 19 passed; restored, blob == HEAD a920e032dcdc, git diff HEAD empty. Ablation 2: the cause field dropped from the refusal log line; anchor 1->0, blob ->a7bb3a4e349f; 1 failed (the log pin) / 26 passed; restored, blob == HEAD, diff empty. No dist on the path: the pins import ./api-trigger.js, and @objectstack/core is aliased to source in vitest.config.ts.",
    "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack: 86 commands, the same list before and after the last commit (stderr line 1 names objectstack-ai/objectstack at 9f82771). All 86 ran at 9f82771, exit 0. dispatch-gates --ran with ':: exit N' lines: '86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED (a DERIVED zero — all 86 recorded an exit code and none of them is 3)'. Also run, exit 0: spec check:error-code-provenance, check:error-code-casing, check-changeset-fixed.mjs, check:route-ledger-census. A first pass before the last commit had 8 exit-3 prerequisite refusals (stale spec dist, unbuilt formula/objectql/workspace dist). These were re-run after rebuilding and all exited 0. Not run locally (CI's): the path-scheduled CI jobs and type-check lanes dispatch-gates lists outside its derivation, and repo-wide pnpm lint.",
    "line_budget": "n/a: the diff touches no skills/
    and no line-ratcheted ledger. 4 files, +265/-31.",
    "files_changed": [
    ".changeset/22806-uniform-hook-refusal.md (+16/-0; minor for @objectstack/trigger-api and @objectstack/spec, Clause-②: yes, FROM -> TO)",
    "packages/spec/src/contracts/trigger-api-service.ts (+17/-10; handleInboundHook docblock only)",
    "packages/triggers/trigger-api/src/api-trigger.ts (+67/-11)",
    "packages/triggers/trigger-api/src/api-trigger.test.ts (+165/-10)"
    ],
    "deviations": [
    "Lock conflict between the order and os-dev.md: the order puts every gate run under os-verify-lock; os-dev.md describes check:* gates as running outside it. Gates ran under the lock in four chunks (the more restrictive reading). Flagged here, not silently resolved.",
    "Commit f26b2f4's message says the docblock defers the signature forms to core's verifyHttpSignature. Commit 9f82771 restored the merged signature-form wording, because the accepted forms are outside this dispatch, so that sentence in the first commit message no longer describes the branch. A squash merge composes both.",
    "Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude). The harness reminder's model-bearing Co-Authored-By form was not used, because the pre-push hook refuses it.",
    "No labels written: the order names none, and skip-changeset does not apply because the diff ships a changeset. label-write was not called.",
    "Branch is 3 commits behind origin/main (67b669e). All three touch disjoint packages (runtime/core/platform-objects, plugin-audit, service-sms), so no merge was done; CI tests the merge ref."
    ],
    "mcp_calls": "0",
    "api_writes": "2 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create, executed as POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/22822/assignees [os-project-manager], body read back byte-identical (11032 bytes); (2) this os-dev-report comment, executed as POST /repos//issues/22806/comments. Plus 3 git pushes (not REST).",
    "open_questions": [
    {
    "question": "Which single status do unverified inbound-hook posts get? This is the boundary flag for the contract-tier review. Both codes are already declared.",
    "options": [
    "A: 401 INVALID_SIGNATURE (implemented). A correctly configured sender sees 202, no change. A sender with a bad secret, signing code, re-serialised body or skewed clock still sees 401 and looks at its signature. A sender with a wrong URL (unarmed flow, rotated hook id) sees 401 where it saw 404, and the operator's warn line names unknown-flow / wrong-hook-id. The error-code ledger row '@objectstack/trigger-api: INVALID_SIGNATURE' stays true with no ledger edit. Pending changeset 22769 stays true ('the same 401 INVALID_SIGNATURE a bad signature gets'). The spec edit is docblock only.",
    "B: 404 RESOURCE_NOT_FOUND. A correctly configured sender sees no change. A sender with a bad signature (including a replay or clock outside the window) sees 404 'No such hook', which points it at the URL when the secret is wrong. A sender with a wrong URL sees 404 as before. INVALID_SIGNATURE loses its only emitter, so the spec needs an error-code retirement or keeps a dead ledger row, which goes beyond a docblock edit. Pending changeset 22769's refusal sentence becomes false in the same release."
    ],
    "recommendation": "A, on all four axes. Business need, measured: no SDK client method builds a /automation/hooks/* URL (TRIGGER_API_ROUTE_LEDGER), so no first-party consumer reads the old 404; the showcase arms a custom hook id. A serves the most common sender fault (signing) correctly. Long-term: one code means not-verified, and the declared vocabulary stays true without a ledger change. AI-error: an agent reading 401 fixes the signature; under B it would rewrite URLs while the secret is wrong. Scope: docblock-only spec edit, no new codes, no retirement. Under B the switch is a patch round: the same pins with status and code swapped, plus the ledger decision."
    }
    ],
    "out_of_scope_findings": [
    "carrier: none · noted, not filed. The uniform claim covers status and body only. The code makes no claim about response time across the hook lookup, and that was not measured (inference only, no reach).",
    "carrier: whoever compiles the release notes · noted, not filed. The pending .changeset/22772-spec-trigger-api-inbound-hook-member.md still states the old 404 split. If both ship in one release, this PR's changeset states FROM -> TO beside it. That changeset belongs to another card and is not edited here.",
    "carrier: none · noted, not filed. The handleInboundHook docblock's verification bullet names only the body-only signature form, while the runtime has also accepted the timestamped form since 7b0a9c9 (unreleased docblock drift). The accepted forms are outside this dispatch, so the merged wording is kept. Dedupe words: handleInboundHook docblock signature form timestamped v1.",
    "carrier: none · noted, not filed. The 401 has never carried a WWW-Authenticate header. That is pre-existing and unchanged."
    ]
    }

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22822 at 9f82771b0f · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T11:02Z

    Claim 6107768529. Report 6108319846, checked against GitHub, ⛔ not taken on trust.

    Checklist, as checked:

    • PR shape: draft, base main. The first line is Fixes #22806 and Clause-②: yes starts a line. No other card number sits beside a closing keyword. The assignee is os-project-manager.
    • Scope: pulls/22822/files lists 4 files (+265 / -31):
      • the changeset;
      • api-trigger.ts and its test;
      • packages/spec/src/contracts/trigger-api-service.ts, docblock only, under the cross-domain exception path triage named for this lane (6107336164).
        check-governed-merges.mjs --pr 22822 says NOT governed (0 of 4 paths).
    • The diff, read in full:
      • handleRequest sends the two match refusals and the verify refusal through one refuseUnverified, which returns a fresh copy of one 401 INVALID_SIGNATURE body.
      • The match still runs before the secret read, so the 503 residual answers only a post naming an armed flow and its hook id. This is as triage kept it, and the docblock and the changeset name it.
      • The cause goes to one warn line, as the structured fields { cause, flowName }. cause is a closed union built from verifyHttpSignature's own reason. The message text interpolates only the cause and fixed prose, ⛔ never the posted flow name, the secret, the signature or the body.
      • The spec edit changes no type.
    • Changeset (@objectstack/trigger-api and @objectstack/spec, both minor), checked sentence by sentence:
      • "a post that named no armed flow, or the wrong hook id, answered 404 … Now all four answer 401 INVALID_SIGNATURE with the same body": matches the diff.
      • "A correctly configured sender … still gets 202": matches the controls.
      • The six cause values equal the union.
      • "A wrong hook id never reaches the secret read": the match precedes the secret read.
      • The semver level is the contract review's ② to confirm.
    • Ablations: putting the old 404 back on the match branch turned exactly the 8 base-red pins red. Dropping the logged cause turned exactly the log pin red. Each restore was proven by blob equality and an empty diff.
    • Disclosure: the measurement table in the body restates the class the card already states (armed versus unarmed names under the default hook id) and adds no identifiers. The test names are neutral.

    The boundary flag, the status (401 implemented and recommended, versus 404): ⛔ not this seat's call. It goes to the at-tier contract review, as triage set it ("the spec seat picks which status in the contract review"). The dev's four-axis case for 401 is in the PR body. A ruling for 404 is a patch round on this claim.

    The PR's acceptance notes, recorded, ⛔ not widened into this PR:

    • the claim covers status and body, not response time;
    • the pending 22772 changeset still describes the old 404 split, so the release notes carry both;
    • the docblock's signature-form bullet predates the timestamped form;
    • one warn per refused post, at request rate.

    Landing: needs:contract-review goes on the PR in this act. The CONTRACT_REVIEW_TIER reviewer is the one subagent batch:1 allows. It reads the full check roster on 9f82771b0f before its verdict. The PR lands only on a PASS on its current head with every check green or an expected skip.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed and closed out · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T12:05Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions