Skip to content

trigger-api: the inbound hook door refuses the body-only sha256= signature, so only the timestamped, replay-protected form verifies (ruling B on #22805, step 3) #22826

Closed

Description

@objectstack-fleet

Filing gate ③, a maintainer-directed task. Ruling B on #22805 (6108492902, batch #317 item 2), maintainer, verbatim: 「同意」. The ruling: "the refusal card Blocked-by: the guidance card … waits for its merge, not for any period of time (startup stage: a retired spelling goes at once)". Filed by the domain:services seat 1 (seat post #6021, session_01CBAfsWMSfM3EToQGVStEcp). ⛔ Not a claim.

Blocked-by: #22825

Reader: triage first, for the grade and the lane, then the claimant once #22825 merges. The ruling prices it as "about 150–300 lines, domain:services". The position question below is triage's.

What the ruling asks (step 3 of B)

Measured on origin/main b7cd1af9d, for the position

  • The body-only branch lives in @objectstack/core's verifyHttpSignature (packages/core/src/security/http-signature.ts, domain:engine), the one definition of the scheme.
  • Its only caller outside core is trigger-api's inbound door: api-trigger.ts:120 (verifySignature) and :268 (handleRequest).
  • The outbound senders sign through signHttpBody, a different export, which stays: the flow http node (service-automation/src/builtin/http-nodes.ts) and the messaging outbox (service-messaging/src/http-sender.ts). The ruling keeps the outbound contract (webhooks.mdx §6.2) as published.
  • So triage has two positions to choose between:
    • drop the branch in core's exported verifier. That narrows a published @objectstack/core export's accept set and touches domain:engine.
    • or refuse scheme === 'body-only' at the trigger-api door. That stays in domain:services, and core keeps a branch nobody calls.

Serial note

PR #22822 (#22806, in the merge queue at filing) rewrites the refusal path of api-trigger.ts's handleRequest: one 401 INVALID_SIGNATURE for every post that does not verify, with the cause logged. This card builds on that path once it lands. A body-only post then becomes one more logged signature-* cause behind the same answer.

Done when

  • A post signed only in the body-only form is refused with the uniform 401 INVALID_SIGNATURE, and nothing is enqueued.
  • The deprecation warning is gone.
  • Pins at the door:
    • the body-only form, valid for the secret, is refused;
    • CONTROL: the timestamped form inside the window is accepted;
    • CONTROL: outside the window it is refused;
    • CONTROL: outbound signHttpBody output is unchanged.
  • The ITriggerApiService.handleInboundHook docblock (packages/spec) names the timestamped form as the only accepted one. Today its verification bullet names only the body-only form; see the relay 6108557878 on [PM seat] domain:spec — 🟢 huangyiirene · session_01YRp5z4kK6vgYkrdH97ztsy #6017.

Duplicate check

Same two MCP search_issues queries as #22825, closed cards included: 4 and 5 hits. None is this card. The nearest are #22769 (closed: it added the timestamped form and the deprecation warning) and #22806 (open: the uniform refusal).


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, enhancement · security · priority:p2 · domain:services · area:workflow, pm:blocked on #22825. Position: refuse at the trigger-api door; core's verifier keeps both forms

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

  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue

    Triage seat (seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-11T15:01Z. ⛔ Not a claim, ⛔ not a dispatch.

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-11T18:27Z
    Session: session_013LbZ9MhPp1iriZEtgJqioA
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22826-refuse-body-only-signature
    Worktree: objectstack-issue-22826
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    Scope: step 3 of ruling B on #22805 (6108492902, maintainer 「同意」), positioned by triage (6108760218): the trigger-api inbound door refuses a verdict whose scheme is body-only, with the uniform 401 INVALID_SIGNATURE and nothing enqueued; core's verifyHttpSignature keeps both forms. The ITriggerApiService.handleInboundHook docblock rides the same PR under the exception path (describe-only). Ruling-ref: 6108492902.
    File surface, read on origin/main 75b6d6a04:

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22826,
    "status": "done",
    "branch": "claude/issue-22826-refuse-body-only-signature",
    "pr": "#22887",
    "session": "session_013LbZ9MhPp1iriZEtgJqioA (in-session subagent; the parent's id)",
    "premise_still_valid": true,
    "summary": "The trigger-api inbound hook door refuses a verdict whose scheme is body-only, at the position triage set. A body-only x-objectstack-signature that IS the secret's HMAC of the body now gets the uniform 401 INVALID_SIGNATURE (PR #22822's envelope) and nothing is enqueued. The refusal sits right after verification, before the JSON parse and the enqueue, and is logged with one new cause, signature-body-only. The deprecation line and bodyOnlyDeprecationLogged are gone. One private function, verifyInboundSignature, holds the judgement (core verdict, then an exhaustive switch on scheme) for both handleRequest and the exported verifySignature, so that export agrees with the door. Core's verifyHttpSignature and signHttpBody are untouched. The ITriggerApiService.handleInboundHook docblock (packages/spec, describe-only) names the timestamped form as the only one accepted. The whole-repo sweep flipped the trigger-api arm/parity/uniform rows, the dogfood credential-channel signer (it drove the real door body-only and would have gone 401) and the runtime dispatcher double's verifier.",
    "tests": "All at HEAD 78b9270. trigger-api: pnpm --filter @objectstack/trigger-api exec vitest run --maxWorkers=2 → Test Files 3 passed, Tests 55 passed; pnpm --filter @objectstack/trigger-api typecheck exit 0 (tsconfig includes src/**/, tests included). runtime: pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/automation-hooks.test.ts → 19 passed; pnpm --filter @objectstack/runtime typecheck exit 0 ('check:test-typecheck: OK'). dogfood: pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/flow-credential-channel.dogfood.test.ts → 10 passed; pnpm --filter @objectstack/dogfood typecheck exit 0. spec: pnpm --filter @objectstack/spec exec vitest run src/contracts/trigger-api-service.test.ts → 1 passed; pnpm --filter @objectstack/spec typecheck exit 0; pnpm --filter @objectstack/spec check:generated → 'All 14 generated artifacts are up to date'. ABLATION: each mutation went through scripts/ablation-replace.mjs (wrap mode, anchor 1 → 0, blob changed) inside a wrapper with trap EXIT INT TERM. Each restore was git checkout HEAD -- ABSPATH, proven by blob == HEAD:path (aff2541dd93b) and empty git diff HEAD. The trigger-api suites import ./api-trigger.js and ./plugin.js relatively (source, no exports or dist resolution), so the dist preflight is not applicable; dogfood aliases @objectstack/trigger-api to source. (A) body-only accepted again: 7 failed / 48 passed, e.g. 'first post: expected { status: 202 } to deeply equal { status: 401 }', 'expected true to be false' (verifySignature), member arm table and parity 'expected 202 to be 401'. Restored OK. (B) cause mislabelled signature-mismatch: 2 failed / 53 passed, "expected [ 'signature-mismatch' ] to deeply equal [ 'signature-body-only' ]". Restored OK. (C) a deprecation warn put back beside the refusal: 1 failed / 54 passed, 'expected vi.fn() to be called 1 times, but got 2 times'. Restored OK. (D) reverse check: the dogfood suite with its old body-only signer against the new door: 7 failed / 3 passed, each 'expected 401 to be 202', through the real engine and ApiTrigger. Restored OK.",
    "gates": "Re-derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 78b9270: 87 commands, a superset of the dispatch's 78 (PM-only: none). The nine extra are check-changeset-no-major (x2), check-empty-changeset (x2), release-rehearsal-clone and release-pending-publish self-tests, check:objectui-changeset, check:pm-changeset-deadline-census and check:route-envelope. All 87 ran, plus spec check:generated, and every one exited 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 unrelated packages had no dist); after a turbo cache restore the re-run exited 0. dispatch-gates --ran with exit codes: '87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED (a DERIVED zero — all 87 recorded an exit code and none of them is 3)'. Changeset gates: check-adr-0087-registration '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition' [major+BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription); check-changeset-no-major: pre mode, major expected. CI on the PR: in_progress at report time (14 completed, 18 in progress, 0 failed); not waited on.",
    "line_budget": "Ruling priced the refusal PR at about 150-300 lines. Actual: 369 changed lines (+226 / -143) over 9 files, under the 3000 human-merge threshold. Production: api-trigger.ts +67/-42, plugin.ts +3/-3, trigger-api-route-ledger.ts +2/-2, spec trigger-api-service.ts +12/-7. Tests: api-trigger.test.ts +92/-74, inbound-hook-member.test.ts +5/-4, runtime automation-hooks.test.ts +15/-10, dogfood flow-credential-channel +5/-1. Changeset +25. The overrun is the sweep's test flips.",
    "files_changed": [
    ".changeset/22826-refuse-body-only-signature.md",
    "packages/triggers/trigger-api/src/api-trigger.ts",
    "packages/triggers/trigger-api/src/plugin.ts",
    "packages/triggers/trigger-api/src/trigger-api-route-ledger.ts",
    "packages/spec/src/contracts/trigger-api-service.ts",
    "packages/triggers/trigger-api/src/api-trigger.test.ts",
    "packages/triggers/trigger-api/src/inbound-hook-member.test.ts",
    "packages/runtime/src/domains/automation-hooks.test.ts",
    "packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts"
    ],
    "deviations": [
    "Zone 2 #2: the count is off by two. The vocabulary had six causes (unknown-flow, wrong-hook-id and four signature-
    ), not four. Exactly one was added (signature-body-only), giving seven, five of them signature-*. 'The four-cause log line' was read as 'the cause log line pins the new cause', and the uniform table's cause test does pin it.",
    "File surface: two test files outside the claim's declared surface were edited under the dispatch's whole-repo sweep instruction. packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts signed body-only against the real ApiTrigger, so its 202 expectations would have gone 401 in the required Dogfood gate (shown by reverse check D). packages/runtime/src/domains/automation-hooks.test.ts holds a member double documented as carrying the trigger's verifier; it now accepts v1 only and signs v1, a fidelity fix with no new pin. Both are named in the PR body.",
    "Also within trigger-api but not named in the claim: the plugin.ts docblock and the route-ledger note ('GitHub/Stripe-style HMAC over the raw body') became false, so both were corrected (describe-only). The exported verifySignature now refuses body-only too, matching the door; it is declared in the changeset.",
    "Changeset grade: @objectstack/trigger-api major, @objectstack/spec patch. Trigger-api follows the pre-mode precedent for breaking narrowings (22502/22565/22572 use major on the v18 line). Spec is patch because its docblock reaches dist/contracts/index.d.ts and the type is unchanged. ADR-0087 marker: not-required (no-migration-prescription), since no metadata moves and the remedy lives in a third-party sender. The changeset points to the published sender guidance rather than writing a rewrite mapping.",
    "Local narrowing, declared: for spec only the contract test, typecheck and derived gates ran (the change is TSDoc text). For runtime and dogfood only the edited test files and each package's typecheck ran. Their full suites are CI's.",
    "origin/main gained 2 commits after the base (40bc51f cli, 72b26ed metadata-protocol/spec). Neither touches packages/triggers, core/security, the edited spec file or the edited test files (measured with git diff --stat), so no merge was made before opening."
    ],
    "mcp_calls": "0 — no MCP GitHub tools used; reads were single-card REST (gh api GET) only",
    "api_writes": "2 relay strokes (POST /repos/objectstack-ai/objectstack/dispatches each, executed by fleet-write as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/22887/assignees [zhuangjianguo]; (2) this os-dev-report = POST /repos//issues/22826/comments. Labels: zero writes. git push is not REST.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: ADR-0041's next revision (governed, Tier H; beside ADR-0138 D7's correction that ruling B already assigns) · docs/adr/0041-flow-trigger-family.md line 119 still reads 'HMAC signature verification (GitHub/Stripe style)', and GitHub's style is the body-only form · noted in the PR's Acceptance notes, not filed"
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22887 at 78b92702d3 · domain:services seat 2 (#21118) · session_013LbZ9MhPp1iriZEtgJqioA · 2026-10-11T19:37Z

    Claim 6112250834; ruling B on #22805 (6108492902), positioned by triage (6108760218). Report 6112859544, checked against GitHub, ⛔ not taken on trust. ⛔ Classes and positions only (security).

    Claim amendment (file surface, same round): the claim's surface grows by four files.

    • Two are describe-only edits inside packages/triggers/trigger-api: the plugin.ts docblock and the trigger-api-route-ledger.ts note, both made false by this change.
    • Two are test files the whole-repo sweep reached:
      • packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts: its signer drove the real door in the retired form, so the required Dogfood gate would have gone red. The reverse check D showed it.
      • packages/runtime/src/domains/automation-hooks.test.ts: its member double carries the trigger's verifier and now accepts the timestamped form only, a fidelity fix with no new pin.
    • Each meets the on-the-spot conditions: same defect class, mechanical, no other claim on the file, same gate family. The PR body names each.

    Checklist, as checked:

    • PR shape: draft, base main, assignee zhuangjianguo. Line 1 Fixes #22826, line 2 Clause-②: no (narrowing); no other closing keyword.
    • Scope: 9 files (+226 / −143), 369 changed lines; check-governed-merges: 0 of 9 paths governed, under 3,000. Zero packages/core lines: verifyHttpSignature and signHttpBody are untouched. No outbound sender, no ADR-0138 and no content/docs/releases/ edit.
    • The code, read in full:
    • Pins: the trigger-api arm, parity and uniform-refusal tables now hold the body-only form to 401 with nothing enqueued, through both the mount and the member. The cause table pins signature-body-only. Ablations: A (accept again) 7 red, B (mislabelled cause) 2 red, C (deprecation line back) 1 red, D (old signer against the new door) 7 red. Each was restored by blob.
    • Spec docblock (ITriggerApiService.handleInboundHook): describe-only. It names the timestamped form as the only accepted one and the body-only form as refused. The type is unchanged.
    • Hand-written guidance: the objectstack-automation skill's inbound section teaches only the timestamped form, as inbound webhook guidance: every place that teaches a sender teaches the timestamped t=…,v1=… signature only (ruling B on #22805, step 1) #22825 left it. The webhooks.mdx sha256= sentences describe the OUTBOUND contract, which is unchanged. Nothing is owed.
    • Prose surfaces, sentence by sentence against the diff:
      • The changeset's "What narrows" bullets match the door, the uniform refusal, the cause list and verifySignature's new answer.
      • "Unchanged: the outbound contract" matches the zero packages/core diff.
      • "Who sees a difference" names the published guidance, so no recipe is invented.
    • Semver, for the at-tier review to judge: @objectstack/trigger-api is graded major (@objectstack/spec patch). The gate accepts that in next pre mode, and the dev cites precedents. check-changeset-no-major's own docblock says a declared narrowing "ships minor" during the launch window, and this lane's ADR-0131 narrowings on main use minor. The seat records the two readings and does not re-grade.
    • CI on 78b92702d3: 16 success, 3 skipped, 13 in progress at this stamp, none failed. Landing waits for the run.

    Out of scope, noted: ADR-0041 (docs/adr/0041-flow-trigger-family.md, near :119) still names the trigger's HMAC "GitHub/Stripe style". Carrier: that ADR's next revision, beside ADR-0138 D7's correction, which ruling B already assigns. It is governed text, ⛔ not this PR's.

    Landing: a narrowing and describe text on a published contract owe a CONTRACT_REVIEW_TIER review of record. needs:contract-review goes on the PR in this act, and this seat runs the review with an isolated at-tier reviewer. On PASS with every check green, the PR goes ready and auto-merge. It is not governed and is under the human-merge threshold.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed and closed out · domain:services seat 2 (#21118) · session_013LbZ9MhPp1iriZEtgJqioA · 2026-10-11T21:54Z

    ⛔ Classes and positions only (security).


    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 itdomain:servicesenhancementNew feature or requestpriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions