Skip to content

fix(platform-objects,plugin-email): stop serving sys_email.headers_json on the generic read path — internal: true + a privileged readback seam - #8705

Merged
qq9340100 merged 2 commits into
mainfrom
claude/issue-8149-sys-email-headers-internal
Aug 14, 2026
Merged

fix(platform-objects,plugin-email): stop serving sys_email.headers_json on the generic read path — internal: true + a privileged readback seam#8705
qq9340100 merged 2 commits into
mainfrom
claude/issue-8149-sys-email-headers-internal

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes #8149

sys_email.headers_json stored custom email headers in cleartext on a table the ordinary data API reads (enable.apiMethods: ['get', 'list']). Custom headers are the ordinary place a credential goes — an SMTP relay's Authorization, a provider token, a routing secret — so this is the same shape #8118 ruled on for sys_http_delivery.headers_json. Per the card's own instruction, this adopts the remedy #8118 landed (PR #8348) rather than deciding it a second time.

Premise re-verified first

The card's unlock re-check step 2 asks for this before any code: sys_email.headers_json still exists on the merged ref, in packages/platform-objects/src/audit/sys-email.object.ts, unflagged. Premise holds — this is the S-sized consumer application of a per-object mechanism, not an already-delivered card.

The change

  1. headers_json is declared internal: true. The engine omits it from every generic read — list, get, an explicit ?select=headers_json, write-response bodies — with no system carve-out (api-key-ui-lifecycle (secondary): the key column (SHA-256 hash) serializes over the data API, contradicting its own "never exposed to clients" description #7728's design). SYSTEM_CTX does not reopen it, which is pinned as its own assertion.
  2. The delivery paths recover it through the privileged accessor. ObjectQL.resolveInternalField is consumed unchanged — no packages/objectql file is touched in this PR.

Why a readback seam was required (the half a bare flag would have broken)

sys_email is not delivered from the in-memory message but from the row. Three paths re-read a persisted row and hand it to EmailService.deliverPersistedRow:

  • the after-insert outbox drain hook (email-plugin.ts),
  • the email.send.async queue subscriber,
  • the boot outbox sweep (outbox-sweep.ts).

All three read through engine.find, which is exactly what the flag empties. A flag alone would not merely have hidden the headers from the data API — it would have dropped them from the mail actually sent, on every durable path, while every row still reported sent. So the recovery ships with the flag, at the one chokepoint all three funnel through.

Fail-closed, unweakened. A message whose authored headers cannot be recovered is not sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row queued, not failed, so the queue retry or the next boot's sweep delivers it intact — the analogue of #8118's claim-TTL revert. The recovery deliberately sits outside the catch that marks rows failed.

Differences from PR #8348 I had to bridge

Tests

New packages/plugins/plugin-email/src/email-headers-internal.integration.test.ts — real ObjectQL, real SysEmail schema, real EmailService. Every redaction pin is paired with its wire pin: a "fix" that merely dropped the headers would satisfy every read-path assertion and break every authenticated relay in production.

Built on the stub-driver pattern objectql/src/internal-fields.test.ts already uses, so the package adds no new workspace dependency, no vitest alias config, and no entry to the shrink-only unaliased-import ledger.

Reverse verification (direction predicted before running): removing internal: true, rebuilding platform-objects, and proving the mutation reached dist/ turns 4 tests RED. The first attempt was a false green — this suite resolves @objectstack/platform-objects/audit from built dist/, so the un-rebuilt ablation ran the pre-mutation artifact. The 2 tests that stay green under ablation are the deliberately-invariant ones (header-less delivery; the accessor refusing an unflagged field). Restoration was re-verified in dist/ afterwards.

Per-package readings, all at 16a35089c:

package result
@objectstack/plugin-email 24 files / 375 passed
@objectstack/platform-objects 20 files / 369 passed
@objectstack/cli 120 files / 1316 passed
@objectstack/plugin-auth 53 files / 1195 passed
@objectstack/lint 72 files / 2024 passed
@objectstack/dogfood 109 files / 771 passed, 1 skipped

typecheck green for both changed packages.

Gate union re-derived against the real changed paths and run at 16a35089c (the final commit): check:changeset-gate-self-tests, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:i18n, check:nul-bytes, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract — 12/12 pass. check:type-check-debt also passes, measured with the full workspace closure built (it refuses outright on an unbuilt worktree, which means NOT MEASURED rather than not-applicable).

i18n bundles regenerated via node scripts/check-i18n-bundles.mjs --write (platform-objects owns an i18n-extract.config.ts); only the en bundle moved.

Deliberately NOT closed

The row still holds the header map in cleartext at rest until retention ages it out — pinned as a test so a future card flips it deliberately rather than by accident. Field.secret() was measured and rejected on #8118 (an orphan sys_secret row per message with no cascade or retention, a boot-window fail-open, a per-row decrypt on every delivery); this PR adopts that ruling unchanged rather than re-litigating it.


Generated by Claude Code

claude added 2 commits August 14, 2026 13:29
…on on the generic read path

internal: true on the column plus a privileged readback seam on the
delivery paths, adopting the remedy #8118 ruled and PR #8348 landed.

Refs #8149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
… changeset + i18n

Drops the driver-memory devDependency in favour of the stub-driver pattern
objectql/src/internal-fields.test.ts already pins the flag against, so the
package adds no new workspace dep, no vitest alias config and no entry to
the shrink-only unaliased-import ledger. The at-rest pin now scans raw
storage instead of reading back through the privileged accessor.

Refs #8149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 14, 2026 3:10pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-email.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via @objectstack/plugin-email)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/plugin-email)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/plugin-email)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/plugin-email)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants