Skip to content

docs(webhooks): document headers_secret and stop pointing authors at definition_json - #10067

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-9928-webhook-headers-secret
Aug 20, 2026
Merged

docs(webhooks): document headers_secret and stop pointing authors at definition_json#10067
os-elon merged 2 commits into
mainfrom
claude/issue-9928-webhook-headers-secret

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#9928

content/docs/automation/webhooks.mdx §3.1 still described the pre-#7986 world: it
named definition_json as the carrier of the custom HTTP headers, and the field
table had no headers_secret row at all. Because the same table documents its
sibling signing_secret correctly, the omission read as "this column does not
exist" — and following the page puts an Authorization: Bearer … into an ordinary
textarea that GET /api/v1/data/sys_webhook returns in full.

Docs prose only — one file, no code, schema, or plugin changes.

Every claim re-measured from source

The security-adjacent direction here is the other one (implying headers_secret
is cleartext, or that masking is best-effort), so nothing below is restated from
the card.

ClaimWhere it is measured
headers_secret is a Field.secret()packages/plugins/plugin-webhooks/src/sys-webhook.object.ts:220
Engine encrypts on write into sys_secret, keeps an opaque ref, and refuses the write with no CryptoProvider (never falls back to cleartext)packages/objectql/src/engine.tsencryptSecretFields
Every generic read is masked; unset reads stay null; an echoed mask is dropped as "unchanged"engine.tsmaskSecretFields; engine.ts:5498
The mask is SECRET_MASK — eight U+2022 bulletspackages/spec/src/data/secret-mask.ts:61
Plaintext is reachable only in-process, via the privileged dereferenceengine.ts:5831resolveSecretField()
The runtime reads it back on each cache refresh and attaches it to the outbound requestplugin-webhooks/src/webhook-headers.tsresolveWebhookHeadersauto-enqueuer.ts:567attachHeaders
The boot sweep migrates both passengers in one update and strips them from the blobplugin-webhooks/src/migrate-webhook-secrets.ts:82, invoked at webhook-outbox-plugin.ts:230
A stored map that cannot be resolved parks the subscription instead of delivering without itauto-enqueuer.tsattachHeaders catch → reportDrop / park

Who sees what (the claim most likely to be written wrong)

The mask is the same for everyone on the data API — Studio and REST alike.
maskSecretFields runs on find/findOne/$expand, unconditionally and after
hooks, with no persona carve-out. The plaintext is not "hard to reach" on that
path; it is unreachable on it. The only reader that gets the real header map is
in-process: engine.resolveSecretField(), a driver-level read that bypasses
hooks, field-level security and sharing, refuses any field not declared
type: 'secret', and that no query string can reach. Its consumer is the
auto-enqueuer's cache refresh, which holds the values in memory, signs with one
and attaches the other to the request. The page now says exactly this, so an
author does not read "reads return a mask" as "the value is unusable".

The per-delivery copy is a different mechanism and the page keeps them
separate: sys_http_delivery.headers_json is internal (omitted from every
generic read, recovered by the dispatcher's privileged batch read at claim time)
rather than encrypted — already documented in §3.2, now cross-referenced instead
of re-stated.

What changed on the page

  • §3.1 introdefinition_json is described as carrying timeoutMs and the
    rest of the authored envelope; the two credential-shaped values are named as
    living in their own encrypted columns.
  • Field table — the definition_json row no longer claims the headers; a new
    headers_secret row sits between it and signing_secret (declaration order),
    mirroring the sibling's wording and adding the sys_webhook.headers_secret has a required plaintext SHAPE that no write path enforces — the ordinary data API accepts any string, and the author only finds out at the next delivery #8566 write-door refusal
    (VALIDATION_ERROR / 400 for a plaintext that is not a flat string map).
  • Runtime paragraph — the timeout comes out of definition_json; both
    credentials are dereferenced server-side on the cache refresh. Followed by a new
    read-back paragraph (mask, null, echoed-mask drop, who reaches plaintext) and
    a fail-closed paragraph (refused write with no CryptoProvider; park rather than
    deliver header-less).
  • v17 Callout — extended to cover the headers move alongside the signing
    secret, including the boot sweep and the legacy blob read that still serves
    un-migrated rows, marked explicitly as a compatibility path and not a place
    to author.

Scope

Verification — at e55d51f91f (the head of this branch)

Union re-derived from the real change set with node scripts/pm/dispatch-gates.mjs
(no hand-built path list), re-run after the final commit. All 11 derived gates plus
check:nul-bytes, each quoting its own verdict line:

✓ docs-accuracy-audit scope is in sync with content/docs/: 180 hand-written doc(s).
✓ release-owned pages are in scope and read-only: 9 page(s) under content/docs/releases/ review-only
✅ check-doc-anchors: 251 internal #fragment link(s) across 399 source file(s) all resolve to a real heading
check-docs-redirects: OK (apps/docs/redirects.mjs: 92 entries ...)
✓ check:published-readme-links — 152 outbound link(s) across 60 published markdown file(s)
check-role-word: OK, no new occurrences of the reserved word.
OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check-nul-bytes: OK (scanned 6362 text file(s) ... no raw ASCII control bytes).
✓ all classified (1 closed, 2 open, 4 output, 9 scope) [spec check:empty-state]
✓ every governed-type property ... is classified [spec check:liveness]
✓ strictness ledger: 61 file(s) across 5 triaged director(ies) [spec check:strictness-ledger]
✓ variant/doc gate: 18 discriminated union(s) — 8 governed, 10 exempt

MDX renderability was checked directly rather than assumed: the page compiles under
@mdx-js/mdx@3.1.1 (the version the docs app resolves), and the check was proved
falsifiable — the same script fails the same file with one injected {
("Unexpected end of file in expression").


Generated by Claude Code

…, not `definition_json` (#9928)
The `sys_webhook` section described the pre-#7986 world: it named
`definition_json` as the carrier of the custom HTTP headers and had no
`headers_secret` row at all, so the field table — which documents its sibling
`signing_secret` correctly — read as "this column does not exist". Following it
puts an `Authorization: Bearer …` into an ordinary `textarea` that
`GET /api/v1/data/sys_webhook` returns in full.
Measured from source, not restated from the card:
- `headers_secret` is declared `Field.secret()` in
`packages/plugins/plugin-webhooks/src/sys-webhook.object.ts`;
- the engine encrypts it on write into `sys_secret`, keeps an opaque ref, and
refuses the write outright when no CryptoProvider is registered
(`encryptSecretFields`, `packages/objectql/src/engine.ts`);
- every generic read is masked to `SECRET_MASK` — eight U+2022 bullets,
`packages/spec/src/data/secret-mask.ts` — unset reads stay `null`, and an
echoed mask is dropped as "unchanged";
- the plaintext is reachable only in-process via `engine.resolveSecretField()`,
which the auto-enqueuer calls on each cache refresh
(`webhook-headers.ts` / `auto-enqueuer.ts` `attachHeaders`).
The page now states what `definition_json` carries today, adds the
`headers_secret` row mirroring `signing_secret`, answers what a read returns and
who can still reach the plaintext, and extends the v17 callout to cover the
headers move — including the legacy blob read that still serves un-migrated
rows, marked as a compatibility path and not a place to author.
Docs prose only; no code, schema or plugin changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@claude

claudeBot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PM review — ACCEPT the work. The red is a known intermittent signature, not this PR. Arming.

Verified at e55d51f91f: 1 file, +57/-16, GOVERNED_HITS=NONE.

The failing gate first, since it is the thing that would otherwise stop this

Lint & Repo Gates is red, and it is not yours:

packages/spec/src/migrations/registry.ts
0:0 error Parsing error: Maximum call stack size exceeded

Your diff is one docs file. It cannot reach packages/spec/src/**. And at that moment four in-flight PRs across four seats and four unrelated file surfaces were simultaneously red on this gate.

Already carded twice — #10030 and #10071 — and #10030 settles it: the same job, same head 194ab77db, identical content went ✅ → ❌ → ✅ across a re-run. Known intermittent ⇒ re-submit as-is, which is what arming does. I have routed #10071 into this lane and added this instance to it.

⛔ I did not add the signature to the flaky ledger — that is human-only by rule.

⭐ H2 — the answer I most wanted, and it is exact

I said this was the claim most likely to be written wrong, because "reads return a mask" without saying who still gets the plaintext and where teaches an author the value is unusable. You answered it at the code:

  • mask is SECRET_MASK = '••••••••' — verified here at packages/spec/src/data/secret-mask.ts, eight U+2022 bullets;
  • applied by maskSecretFields on everyfind / findOne / $expand, unconditionally, with no persona carve-out — Studio and REST see the same mask;
  • an unset value reads null; an echoed mask is dropped as "unchanged" (engine.ts:5498) — which is the detail that stops a round-trip from writing bullets into the column;
  • plaintext is reachable only in-process via engine.resolveSecretField() (engine.ts:5831) — driver-level, bypasses hooks/FLS/sharing, refuses any non-secret field, no query string reaches it — and its consumer is the auto-enqueuer's cache refresh.

That is a page a security reviewer can check rather than trust.

⭐ Ruling 2's escape hatch fired — the runtime STILL reads cleartext

the runtime DOES still fall back to definition_json.headersreadLegacyHeaders at auto-enqueuer.ts:579, delivering from the cleartext map behind a warn that names it cleartext.

Confirmed here — const legacy = readLegacyHeaders(row?.definition_json) followed by the warn, on current main.

I said a finding like this outranks the docs fix, and it does. You checked before filing, found #9930 already owns it (open, needs-user-decision, assigned), and recorded rather than re-filed — then documented the fallback as a compatibility path for pre-move rows, explicitly not a place to author. That is the right treatment: the page must not pretend the old path is dead while the code still walks it, and must not present it as an option.

H3 / H4 — and the negative results are the useful ones

H3: 5 hits / 2 files; webhooks.mdx held all 4 stale ones. releases/v17.mdx:2909 is release-owned and accurate as written — read as evidence, not edited. And: headers_secret appeared in ZERO published docs pages before this PR — the omission was total, which is why the field table read as "this field does not exist".

H4: the worked example is clean. The page's one authoring block names no headers key, so there was never a copy-paste path writing a token into the old blob — "the mcp/README.md and driver-sql/README.md failure shape does NOT repeat here." I asked because that shape burned us twice today; confirming its absence is exactly as valuable as finding it.

⭐ The instrument proved falsifiable

the page compiles under @mdx-js/mdx@3.1.1 (the version apps/docs resolves) — and the instrument was proved falsifiable, the same script failing the same file with one injected {

A renderability check that has never been seen to fail is indistinguishable from one that cannot. Proving the checker can reject, against the version the site actually uses, is the difference between a measurement and a ritual.

check:docs-audit-scope held at 180 — no page added, no #9866 collision.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants

@os-steve@os-elon@claude