Found while implementing #8558 (measured, not traced). Out of scope there and deliberately not fixed on that branch — #8558 is scoped to the consumer seam (resolveWebhookHeaders must not fold "stored but unusable" onto "no headers authored"), and this is about not creating the state at the door. Unassigned, no pm:queue, for triage to grade.
The defect
sys_webhook.headers_secret is a Field.secret() whose plaintext is not an opaque blob: it is a serialized header map with a required shape — a flat JSON object of string values — and parseStoredHeaders is the only reader.
Nothing validates that shape on the way in. The ordinary data API accepts any string, encrypts it like any other secret, mints a real sys_secret row, and leaves the column holding a perfectly valid secret: ref that reads back as the mask with active: true.
That field is directly admin-authorable, and its own description tells the author what to type into it:
Custom HTTP headers sent with each delivery, as a JSON object ({"Authorization": "Bearer …"}). Encrypted at rest into sys_secret; reads return a mask, never the headers.
So the product asks for a JSON object, accepts anything, and stores it.
Evidence — measured on a real engine
Reproduced against a real ObjectQL engine (in-memory driver double, reversible test crypto, the real sys_webhook schema), on origin/main at 719a21bfc, through engine.update() — the ordinary data API, no privileged access:
written to headers_secret | write | column at rest | read back | resolves to |
|---|
{} | accepted | valid secret: ref | mask, active: true | {} |
[] | accepted | valid secret: ref | mask, active: true | [] |
{"X-Count":5} | accepted | valid secret: ref | mask, active: true | {"X-Count":5} |
{"X-Team":{"name":"crm"}} | accepted | valid secret: ref | mask, active: true | the nested object |
{X-Team: crm} (a typo) | accepted | valid secret: ref | mask, active: true | the typo, verbatim |
Every one of them is a value the plugin can never use.
Why it is worth fixing rather than tolerating
Before #8558 this was silent and open: the subscription armed and the delivery went out missing its entire authored header map, correctly signed, status: 'success', nothing logged.
After #8558 it fails closed and loudly — the subscription parks, the discarded event lands in sys_http_delivery, and the operator gets a remedy-bearing error. That is the right consumer behaviour and it is not in question here.
What remains is when the author learns. Today the answer is "at the next matching record change", which is an unbounded time after the mistake and in a completely different surface from the one where it was made. The mistake is a typo in a form field; the report is a parked subscription and a log line. A rejection at the write door would put the diagnosis where the author is standing.
This is also the shape the repo's own contract-first rule names: a lenient door plus a strict consumer is exactly where AI-authored metadata errors hide, since an agent writing headers_secret from a template has no feedback that the value it produced is unusable until a delivery is missed.
Options (⛔ not decided here)
- Validate at the plugin's write paths (
bootstrapDeclaredWebhooks / headersPatch / the migration sweep). Cheapest, entirely inside plugin-webhooks. ⛔ But it does not cover the road that actually matters: a direct PATCH /api/v1/data/sys_webhook never goes through them, and that is the measured trigger above. - A validation hook on
sys_webhook that parses headers_secret before the engine encrypts it, refusing with a located ADR-0112 VALIDATION_ERROR that names the required shape. Covers the ordinary data API, which is the point. Cost: the check has to run on the plaintext, i.e. before encryptSecretFields, and has to skip an echoed mask. - A general capability on the
secret channel: let a secret-typed field declare a plaintext validator, so any field whose ciphertext has a required shape gets the same door. Most principled and the only one that generalizes past webhooks; also the largest, and it is a spec/engine surface rather than a plugin one.
A decision here should probably be taken together with #8559, which asks the adjacent question about the same door — what a secret field should do with "". The empty-string case reaches headers_secret too, identically (measured: accepted, encrypted, valid ref, resolves to ""), so #8559's verdict decides one row of the table above and this card decides the rest.
Explicitly NOT claimed
Related: #8558 (the consumer seam, fixed), #8559 (the same door, "" half), #7986 (the channel this field lives on).
Found while implementing #8558 (measured, not traced). Out of scope there and deliberately not fixed on that branch — #8558 is scoped to the consumer seam (
resolveWebhookHeadersmust not fold "stored but unusable" onto "no headers authored"), and this is about not creating the state at the door. Unassigned, nopm:queue, for triage to grade.The defect
sys_webhook.headers_secretis aField.secret()whose plaintext is not an opaque blob: it is a serialized header map with a required shape — a flat JSON object of string values — andparseStoredHeadersis the only reader.Nothing validates that shape on the way in. The ordinary data API accepts any string, encrypts it like any other secret, mints a real
sys_secretrow, and leaves the column holding a perfectly validsecret:ref that reads back as the mask withactive: true.That field is directly admin-authorable, and its own description tells the author what to type into it:
So the product asks for a JSON object, accepts anything, and stores it.
Evidence — measured on a real engine
Reproduced against a real
ObjectQLengine (in-memory driver double, reversible test crypto, the realsys_webhookschema), onorigin/mainat719a21bfc, throughengine.update()— the ordinary data API, no privileged access:headers_secret{}secret:refactive: true{}[]secret:refactive: true[]{"X-Count":5}secret:refactive: true{"X-Count":5}{"X-Team":{"name":"crm"}}secret:refactive: true{X-Team: crm}(a typo)secret:refactive: trueEvery one of them is a value the plugin can never use.
Why it is worth fixing rather than tolerating
Before #8558 this was silent and open: the subscription armed and the delivery went out missing its entire authored header map, correctly signed,
status: 'success', nothing logged.After #8558 it fails closed and loudly — the subscription parks, the discarded event lands in
sys_http_delivery, and the operator gets a remedy-bearingerror. That is the right consumer behaviour and it is not in question here.What remains is when the author learns. Today the answer is "at the next matching record change", which is an unbounded time after the mistake and in a completely different surface from the one where it was made. The mistake is a typo in a form field; the report is a parked subscription and a log line. A rejection at the write door would put the diagnosis where the author is standing.
This is also the shape the repo's own contract-first rule names: a lenient door plus a strict consumer is exactly where AI-authored metadata errors hide, since an agent writing
headers_secretfrom a template has no feedback that the value it produced is unusable until a delivery is missed.Options (⛔ not decided here)
bootstrapDeclaredWebhooks/headersPatch/ the migration sweep). Cheapest, entirely insideplugin-webhooks. ⛔ But it does not cover the road that actually matters: a directPATCH /api/v1/data/sys_webhooknever goes through them, and that is the measured trigger above.sys_webhookthat parsesheaders_secretbefore the engine encrypts it, refusing with a located ADR-0112VALIDATION_ERRORthat names the required shape. Covers the ordinary data API, which is the point. Cost: the check has to run on the plaintext, i.e. beforeencryptSecretFields, and has to skip an echoed mask.secretchannel: let asecret-typed field declare a plaintext validator, so any field whose ciphertext has a required shape gets the same door. Most principled and the only one that generalizes past webhooks; also the largest, and it is a spec/engine surface rather than a plugin one.A decision here should probably be taken together with #8559, which asks the adjacent question about the same door — what a
secretfield should do with"". The empty-string case reachesheaders_secrettoo, identically (measured: accepted, encrypted, valid ref, resolves to""), so #8559's verdict decides one row of the table above and this card decides the rest.Explicitly NOT claimed
headersare still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986's encrypted header channel.resolveWebhookHeaders— the delivery goes out MISSING its authored headers #8558 made the consumer half fail closed and loud; this card is only about moving the diagnosis to the door.Related: #8558 (the consumer seam, fixed), #8559 (the same door,
""half), #7986 (the channel this field lives on).