Skip to content

fix(platform-objects,plugin-audit,plugin-approvals)!: mail bodies are read only with manage_platform_settings, and the audit mirrors never copy them in clear - #24688

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24625-identity-mail-read-gate
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24625-identity-mail-read-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #24625
Clause-②: yes (narrowing)

Dispatched by the epic seat (epic:#15194) under claim 6278944518, on the maintainer's order. Measured record: 6278938192.

The defect, re-measured on main bfc1539fe

The test this PR adds was run on unchanged main, over #24552's 18.0 fixture under single. The fixture holds one 18.0 password-reset row and one organization-invitation row in sys_email. Result: 9 red, 11 green, matching the seat's matrix. The 9 red were:

  • An organization administrator without manage_platform_settings was served body_text and body_html of the 18.0 reset row on /data list, get and query.
  • Global search found the reset row by a term from its body, for that same administrator.
  • The sys_activity and sys_audit_log mirrors of the reset row served the body to that administrator.
  • On HEAD, the mirror rows written for a new sys_identity_email row held its body in clear at rest. So did the mirror rows for a new sys_email row.

Anonymous (401), member (403, no search hit), and the identity log for both non-platform principals (403) were already closed.

The fix, and why this one

The selector question first. No column tells an 18.0 identity row in sys_email apart from tenant mail:

  • A magic link carries no related_object, and sys_email has no template column.
  • Notification mail (service-messaging's email channel) also carries no related_object.

So a row gate of "related to sys_user or to nothing" would hide every notification from the organization administrator. Per the dispatch, with no reliable selector the fix is field-level.

  1. Read gate (platform-objects). body_text and body_html on sys_email and sys_identity_email declare requiredPermissions: ['manage_platform_settings'] (ADR-0066 D3). This one declaration closes every door that serves rows through the engine:

    The administrator keeps every row: envelope, subject, delivery state and linkage. They lose only the bodies.

  2. Mirror writers (plugin-audit). The ledger view masks both logs' body columns on every row, the way it masks a credential field. A non-empty body is stored as the read mask and an unset one stays null. This covers the create, update and delete snapshots and the activity row. The two names live once, in platform-objects/src/audit/mail-log-body.ts.

  3. A shipped defect this change would have triggered (plugin-approvals). sys_approval_request free-text search auto-included payload_json. The snapshot guard refuses any query naming that column to a reader withheld any field of any object. Once mail bodies are withheld from the organization administrator, that administrator's whole global search sweep returned 403 (measured on this branch before the fix). Their approvals list search would also have failed. The object now declares searchableFields = the ADR-0061 auto-default minus payload_json. A test holds the declared list to that derivation. A structured filter on the snapshot is unchanged for the readers the guard admits.

What is not changed.

Tests: principal × door

packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts runs over the 18.0 fixture: 22 cases. Every probe is a link-token span read as the system and compared by inclusion, so no value appears in a title, message or log.

principal /data list / get / query body filter global search sys_activity / sys_audit_log sys_identity_email
anonymous 401 — — — —
member 403 — no hit no body 403
organization admin rows served (identity row and its own invitation), no body 403 200, no hit rows served, no body; body probe 403 403
platform admin (control) bodies served 200, one row hit 18.0 copy served 200 with body

The writers are pinned too: the mirror rows of a HEAD-written sys_identity_email row and of a HEAD-written sys_email row hold no body at rest. Unit tests:

  • platform-objects/src/audit/mail-log-body.test.ts: the declaration, with no object-level gate on sys_email.
  • plugin-audit/src/audit-internal-field-omission.test.ts: the mask on every snapshot side, null preserved, and a non-mail-log control.
  • plugin-approvals/src/sys-approval-request.search-fields.test.ts.

Reverse verification. Each leg used scripts/ablation-replace.mjs, then a rebuild of the package, then ablation-dist-preflight (marker present, then absent after restore). Each leg was restored to the HEAD blob with git diff HEAD empty.

  • A1: body requiredPermissions removed from sys_email → 8 dogfood red: the organization-admin list, get, query, search and both mirror reads and both mirror probes.
  • A2: ledger mask disabled → 4 plugin-audit unit red and 2 dogfood writer red. The preflight exit code on this leg was read through a pipe, so it is void. The dist reach is shown by the dogfood red instead.
  • A3: payload_json put back into the approvals search set → 1 unit red and the organization-admin global search red.

Proof (head e7f2dd039)

  • R1: pnpm dev -- --fresh -p 38625 boots the showcase. The seeded admin signs in, and /data/sys_email, /data/sys_identity_email, /data/showcase_task and /search answer 200. A password-reset request answers 200. The log has no error lines. The server was stopped and the port is free.
  • R2: upgrade-18x-harness.dogfood.test.ts is green. It ran together with the new matrix, search-skip-unreadable, audit-log-admin-search, activity-text-predicate and signup-organization-census: 6 files, 62 passed.
  • R3: the harness's v17 case is still refused at boot (same run).
  • R4: packages/cli serve-walled-posture-refusal.test.ts (integration project): 5 passed.
  • Packages:
    • Typecheck exit 0 for platform-objects, plugin-audit, plugin-approvals, plugin-email and dogfood.
    • vitest: platform-objects 1196 passed, plugin-audit 935, plugin-approvals 1140, plugin-email 571.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 74 commands, and all 74 exited 0. The --ran reconciliation reads 74 run, 0 NOT-MEASURED.
    • Two commands first exited 3 on environment prerequisites. check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched. check:dual-build-cjs-loads needed a full build. Both were re-run green after the fix.
  • check-governed-merges --branch HEAD: NOT governed, 531 changed lines.

Acceptance notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk


Generated by Claude Code

…atform_settings and mask them in the ledger mirrors

Claude-Session: https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk
Co-authored-by: Claude <noreply@anthropic.com>
…out, so a reader withheld one field keeps its global search

Claude-Session: https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/platform-objects, @objectstack/plugin-approvals, @objectstack/plugin-audit, touching 24 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/platform-objects/src/audit/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 92ce3af404a9b6ca16881a89f2e841d29da52175.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/platform-objects/src/audit/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 92ce3af404a9b6ca16881a89f2e841d29da52175 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 439ecf3e1c4fd93b401eefa07c4c206716957146 — the merge of head e7f2dd03933e75164c156918ab9b4a8a60e7b4f6 into base 92ce3af404a9b6ca16881a89f2e841d29da52175, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 439ecf3e1c4fd93b401eefa07c4c206716957146 && git checkout 439ecf3e1c4fd93b401eefa07c4c206716957146
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 92ce3af404a9b6ca16881a89f2e841d29da52175 e7f2dd03933e75164c156918ab9b4a8a60e7b4f6 && git checkout -B drift-repro 92ce3af404a9b6ca16881a89f2e841d29da52175 && git merge --no-ff e7f2dd03933e75164c156918ab9b4a8a60e7b4f6

node scripts/docs-audit/affected-docs.mjs --json 92ce3af404a9b6ca16881a89f2e841d29da52175

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 92ce3af404a9b6ca16881a89f2e841d29da52175 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e7f2dd03933e75164c156918ab9b4a8a60e7b4f6
Local-runs: none
Scope: packages/platform-objects/src/audit/index.ts, packages/platform-objects/src/audit/mail-log-body.ts, packages/platform-objects/src/audit/mail-log-body.test.ts, packages/platform-objects/src/audit/sys-email.object.ts, packages/platform-objects/src/audit/sys-identity-email.object.ts, packages/plugins/plugin-approvals/src/sys-approval-request.object.ts, packages/plugins/plugin-approvals/src/sys-approval-request.search-fields.test.ts, packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts, packages/plugins/plugin-audit/src/audit-writers.ts, packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts + .changeset/24625-identity-mail-read-gate.md (prose face, read sentence by sentence), packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/field-masker.ts, packages/objectql/src/engine.ts, packages/objectql/src/search-companion.ts, packages/metadata-protocol/src/protocol.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/rest-route-ledger.ts, packages/plugins/plugin-audit/src/audit-plugin.ts, packages/plugins/plugin-audit/src/parent-field-query-guard.ts, packages/plugins/plugin-audit/src/activity-field-redaction.ts, packages/plugins/plugin-audit/src/audit-log-field-redaction.ts, packages/plugins/plugin-approvals/src/payload-predicate-guard.ts, packages/spec/src/data/search-fields.ts, packages/services/service-realtime/src/in-memory-realtime-adapter.ts, packages/services/service-analytics/src/field-read-admission.ts, packages/runtime/src/domains/mcp.ts, packages/qa/dogfood/test/upgrade-harness/populate-fixture.ts, packages/qa/dogfood/test/authz-conformance.matrix.ts, packages/platform-objects/src/audit/sys-identity-email.object.test.ts, packages/cli/src/utils/organization-ownership-apply.ts and packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts (both read on the #24681 head dceaa5646970b309c1c979b6a8f40bcf2e505c3e), scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-fixed.mjs

Reviewed: PR #24688 (draft, base 92ce3af404a9b6ca16881a89f2e841d29da52175, 11 files, +530/-1, three commits), card #24625, the seat's measured record 6278938192, the claim 6278944518, the dev's os-dev-report 6279731318, PR #24587 (merged) and PR #24681 (draft). Every file:line below is read on the head tree e7f2dd039 unless another tree is named. The diff touches no governed surface (the file list carries none of the register's paths). Principals are named by class; no credential, link or body value is written here.

① Derived judgments

1. Every door is closed for a principal without manage_platform_settings — judged door by door, both logs.

The mechanism is one declaration, field requiredPermissions: ['manage_platform_settings'] on body_text and body_html (sys-email.object.ts:116-128, sys-identity-email.object.ts:120-132). The security plugin folds a field whose capabilities the caller does not all hold into a non-readable, non-editable field-permission entry (security-plugin.ts:9338-9341, ADR-0066 D3), the FieldMasker then deletes the key from every served row (field-masker.ts:85, :184, :237), and the same fold is the engine's one answer to "may this caller filter, sort, group or aggregate by this field" (security-plugin.ts:9381-9383, getQueryableFields). So:

  • /data list, get, query, and select= projection: the key is dropped, never masked-in-place; a body predicate is refused. Pinned on the 18.0 fixture for the organization administrator at dogfood :145-159 (list, both rows, no body key), :161-168 (get by id and get with the body named in select), :170-178 (query by id without body; body $contains probe 403); platform-admin control :180-189. Correct.
  • Body filters: refused by the queryable-fields answer above; pinned :176-177, control :186-188. Correct.
  • Global search: searchAll (metadata-protocol protocol.ts:15839) asks the security service getQueryableFields(object, context) per object per caller and hands the engine only that subset (protocol.ts:15920-15936; engine.ts:2037 states the same contract), so a body term is never scanned for this reader and the sweep stays 200. The pinyin companion never takes a requiredPermissions field as a source (search-companion.ts:134-137). Pinned :196-202 (member and organization administrator: 200, no hit, no echo), control :204-208. Correct. Observation, not a defect: the matrix carries no positive control that the organization administrator's subject-term search still hits sys_email; the queryable-subset path says it does, so the changeset's "does not match on a body" is accurate as written.
  • Body as group key or aggregate input: count() and aggregate() run the middleware chain only, with the AST on the operation context (the engine's own prescription at engine.ts:3358: a hook-shaped filter would not narrow a total or a group, the middleware seam does), and both share the one search expander (expandSearchOnOptions at engine.ts:20225 and :20368 into :14066), so the fold's readable: false refuses the body in groupBy, aggregations, having and orderBy exactly as in where. Not probed by the new matrix; closed by the same gate that closes the filter.
  • sys_activity and sys_audit_log mirrors, read side: the redaction serves a mirror only the parent fields the security service serves this reader (resolveServedFields, activity-field-redaction.ts:20-24 and :38; audit-log-field-redaction.ts:24 and :70), so the 18.0 copy is served to the organization administrator without the body; the security(plugin-audit): a filter over the stored activity text answers by row presence: the generic list door evaluates the predicate at rest, before #21081's read-time redaction, so a reader can probe a field value it is not served #21154 guard (parent-field-query-guard.ts:36-42, installed ahead of the read gate at audit-plugin.ts:501-504) refuses a value-column predicate to a reader withheld any field of the parent, pinned or not. Pinned :215-219 (rows served, no body span), :221-228 (probe 403, parent named and unnamed), :230-233 (member, no body), control :235-240 (platform administrator reads the 18.0 copy). Correct.
  • Export: GET /data/:object/export (data-action-routes.ts:911) passes enforceApiAccess('export') then the user-level enforceExportPermission (:937), and projects through security.getReadableFields(objectName, context) (:1172), so a caller who is admitted at all never receives the body columns. The seat's matrix measured the organization administrator refused outright on main. Not re-probed by the new matrix; closed twice by existing gates.
  • Views and related lists: both are served through /data with the caller's context; no second projection exists. Closed by the data door.
  • REST history route: the route ledger carries only the metadata history route (rest-route-ledger.ts:262); a record's history is sys_audit_log, covered above.
  • Realtime / change feed: the engine publishes the full record body (in-memory-realtime-adapter.ts:87-92) to server-internal subscribers only; no client transport is wired, pinned as realtime-delivery-authz (authz-conformance.matrix.ts:453, state experimental). Not a principal door today; a pre-existing latent surface, outside this card.
  • Analytics query: cube members are admitted through the security service's getReadableFields (field-read-admission.ts:28-29), so the body is neither a group key nor an aggregate input there.
  • MCP bridge: fails closed on every sys_* object (runtime/domains/mcp.ts:748).
  • plugin-email's own routes: none serve rows; the plugin registers only its manifest (email-plugin.ts:396 on main). The pipeline reads as the system and is not narrowed (changeset :19), which is the intended carve-out.
  • Attachments: attachments_json (sys-email.object.ts:191) is not gated; it carries attachment metadata and inline attachment content, never the body, and identity mail carries no attachments. Residual nil.
  • sys_identity_email: object-level requiredPermissions (sys-identity-email.object.ts:46) plus the new field gates; pinned :272-276 (organization administrator and member 403), control :278-282.

Door the matrix missed: none found that is open. Doors named above but not probed by the new test: export, aggregate/group-by, analytics; each is closed by an existing gate that the diff does not touch.

2. The field-level choice versus the claim's wish (organization administrators keep their own non-identity bodies).

The dev measured that sys_email has no reliable identity-row selector. PR #24681 does define one for the ceremony (organization-ownership-apply.ts:3351-3377 on dceaa5646): related_object = 'sys_user', OR related_object IS NULL AND related_id IS NULL AND a body that carries better-auth's magic-link sign-in path. Judgment: the dev is right for a READ gate. That selector is raw SQL evaluated once by a migration; the security layer has no declarable row policy over body content (requiredPermissions is object- or field-level, RLS predicates do not read a column's text), and its magic-link arm is a content heuristic. A false negative in a move leaves a row behind, which this PR's field gate still covers; a false negative in a read gate is a served reset link. So the only selector that is reliable for a gate is "every row", which is the field-level answer. The narrowing is the correct interim fix for a p1 credential exposure, and the changeset states it as before / now / unchanged / why / remedy (changeset :17-22), naming that the organization's own notification and invitation bodies are lost and that the row's envelope, subject, state and linkage stay.

The dev's proposal B (relax the sys_email body gate once the ceremony has moved identity rows out) is NOT safe on the step 6 guard alone, for two reasons the seat should hold against any relaxation PR:

  • The ledger keeps pre-fix copies. sys_audit_log rows written before this fix about 18.0 identity rows keep object_name = 'sys_email' and the body in clear in old_value / new_value. The ceremony re-points sys_activity only (organization-ownership-apply.ts:3451-3456 on dceaa5646) and its PR body says the audit log is a ledger and is not rewritten. The ledger redaction serves a copy the fields the data plane serves for the parent (audit-log-field-redaction.ts:24, :70), so relaxing the sys_email body gate re-opens those copies to the organization administrator.
  • The magic-link arm is heuristic: an 18.0 magic-link row rendered from a customized template without the default sign-in path stays in sys_email.
    Conditions for B: (a) a cleanup that masks the body columns of pre-fix sys_audit_log and sys_activity copies at rest (precedent: os migrate audit-metadata-bodies, packages/cli/src/commands/migrate/audit-metadata-bodies.ts), (b) a measured census of residual rows with no related object after the ceremony, and (c) its own Clause-② widening changeset. Until then A stands.

3. The mirrors' masking (plugin-audit). ledgerView masks both body columns of a mail-log row to the read mask (SECRET_MASK), preserving null (audit-writers.ts:1758-1762), after the credential mask (:1729-1735) and the internal-field omission (:1748-1750); the function is the one view taken for the create new_value, the delete old_value, both halves of an update diff, the activity row and the label source (its contract, the doc above :1722). The two names live once (mail-log-body.ts:22-30) and are re-exported from the audit barrel (index.ts:16); plugin-audit depends on @objectstack/platform-objects as a runtime dependency, so the import is publishable. Pinned: audit-internal-field-omission.test.ts:301-354 (all four snapshot sides, null preserved, non-mail-log control, activity ran), dogfood :284-293 (at rest, both logs, HEAD-written rows). Correct, and the same shape as the credential mask.

Pre-fix stored mirror rows: still hold bodies in clear at rest. They are served only to a reader served the body (the platform administrator reads the 18.0 copy: dogfood :235-240), so there is no escalation, but it is a credential copied at rest and the hazard in item 2 above. This is a residual. The dev assigns its carrier to #24581, yet #24681 (the #24581 PR) states it does not rewrite sys_audit_log and re-points sys_activity without masking. So the residual has no carrier in code: a cleanup card is owed (R2 below). The ceremony is the natural place (it already touches the activity rows of every moved mail), or a sibling os migrate command on the audit-metadata-bodies pattern.

4. The sys_approval_request searchableFields change (outside the claimed surface). On main the object declares no searchableFields, so the ADR-0061 auto-default applies (search-fields.ts:242-249, autoDefaultFields :127), and textarea is in the auto set (:52-53); payload_json is a Field.textarea (object :412), so it was scanned. The snapshot guard refuses any query naming payload_json to a reader withheld a field of ANY registered object when the query pins no subject (payload-predicate-guard.ts:38-47, :221-236). Once mail bodies are withheld from every organization administrator, that reader's $search on the object, and the global sweep passing through it, became 403: a shipped latent defect that this narrowing would have turned on for every tenant administrator. The fix declares the auto-default minus payload_json (object :83-96) and pins the derivation so a later textual field goes red rather than silently unsearched (search-fields.test.ts:19-26). Correct, minimal, pinned, and producer-side (PD #12) rather than a per-caller carve-out in the sweep. It changes results for everyone: free-text search no longer matches inside the JSON snapshot, the platform administrator included; a structured filter on the snapshot is unchanged for readers the guard admits. Both are stated in the changeset (:18) and the PR acceptance notes. node_config_json (textarea) stays searched; it is flow node configuration, not a record snapshot.

5. Tests. The matrix file counts 22 cases: data API 6, global search 3, mirrors 8, identity log and writers 5. Platform-admin controls at :180-189, :204-208, :235-240, :278-282 make every "absent" a gate, not an empty fixture. The organization-admin non-body control is :145-159 (subject and status served for the identity row AND the organization's own invitation). No body, link or token value appears in a title, message or log: probes compare a span read as the system by inclusion and report a boolean (:71-77). The "9 red on unchanged main" reading and ablations A1-A3 are the dev's (Local-runs none): A1's 8 red is the matrix minus the two writer pins, consistent; A2's void preflight is disclosed and the dist reach shown by the dogfood red; A3's 1 unit + 1 dogfood red matches item 4. Unit pins: mail-log-body.test.ts:26-38 (both logs gate exactly the two body columns; sys_email keeps no object-level gate). The sibling equality pin (sys-identity-email.object.test.ts:16 on main, fields toEqual) holds because both objects received the identical declaration. Gaps, none blocking: no export probe (closed by two existing gates, measured 403 on main by the seat); no positive subject-search control for the organization administrator; the get-by-id probe accepts either 200-without-key or 403 (:165-166), both closed. The file declares no measured budget while booting a real stack; that is the lane's debt under the suite-weight rule, not this PR's blocker.

6. The 18.x line rule R1-R4 on the merged tree. Dev-reported on e7f2dd039: R1 fresh showcase boot and sign-in with 200 answers, no error lines, server stopped; R2 the harness with five neighbours, 6 files, 62 passed; R3 the v17 case still refused; R4 cli serve-walled-posture-refusal, 5 passed. The PR run's Dogfood Regression Gate completed in 11 s with its three shards skipped, so the harness did not run in CI on this head; it runs on main's push and hourly battery. Under Local-runs none these stand as reported, not re-verified.

7. Interaction with PR #24681 (head dceaa5646). File overlap: zero (set intersection of the two file lists is empty). Neither PR's code assumes the other: #24681's 5b note says nothing on sys_email changes and the exposure is neither widened nor fixed there; this PR touches no ceremony file. One test premise does interact: the new matrix boots the raw 18.0 fixture through bootFixture, which composes OrganizationOwnershipBootGuardPlugin (populate-fixture.ts:63-71). On #24681 that guard refuses an 18.0 database without the ceremony, no escape hatch (its guard header :63-64 on dceaa5646), and its harness asserts exactly that refusal through the same bootFixture (upgrade-18x-harness.dogfood.test.ts:81, :141-159 on dceaa5646). So whichever of the two lands second will red identity-mail-read-gate's beforeAll on the merged tree. Prescription for the second lander (R1 below): run the ceremony on the fixture copy first, as the harness's 1b does, then restate the matrix: the moved 18.0 identity rows are probed in sys_identity_email (object gate), the organization-invitation row keeps the sys_email body-gate probes, and the pre-fix sys_audit_log copies with object_name = 'sys_email' keep the mirror probes (the sys_activity copies will have been re-pointed to the sibling).

8. Semver, Clause-② and the changeset. minor for @objectstack/platform-objects, @objectstack/plugin-audit, @objectstack/plugin-approvals (changeset :2-4), BREAKING flagged (:13) under the launch-window convention (check-changeset-no-major.mjs header); the three are the packages whose published behaviour moves, so the grade is right and the level axis is discharged (Check Changeset success). check-changeset-fixed.mjs governs the fixed group only and is unaffected. Clause-② reads yes (narrowing) on the PR body and the changeset (:9). The gate reads the arm and carries the axis either way (check-changeset-no-major.mjs:1669-1676), and the narrowing arm is what sets this record's tier, so the tier is unaffected; but the reference spells a pure narrowing no (narrowing) (contract-review.md:12), this diff adds no key to any published payload, and the main corpus carries 168 no (narrowing) against 87 yes (narrowing). Non-blocking spelling note (R3). ADR-0087 marker: not-required (no-migration-prescription) with the why (:11). Correct category: nothing is removed, renamed or re-shaped, the three packages publish, no id covers this behaviour, and the change is behavioural; the category's one refusal is a migration prescription in the body (check-adr-0087-registration.mjs:147-153), and the body's Remedy is an operator permission-set remedy, not a consumer rewrite. The changeset is complete: before / now / unchanged / why / remedy, and it names the approvals search change for every reader.

9. CI check-runs on e7f2dd039, read at 2026-10-19T23:54Z (40 runs). success: Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset, Check PR Size, Spec property liveness, Dogfood Regression Gate (aggregator; shards skipped), Lint · ESLint, every Lint · and Type Check · lane, Flag docs affected by code changes, Check Documentation Links, the four PR-protocol checks (card claims branch, no other PR claims the issue or the single-writer path, part-of does not close), Auto Label, filter, Test Core (1/6), (5/6), (6/6). in_progress: Test Core (2/6), (3/6), (4/6). skipped: Console Pin Gate, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate shards, Dogfood Verify CLI, Build Docs, Packed-tarball smoke (opt-in). No failure. The required Test Core context was not yet complete at review time; landing precondition ② (all checks green) is the seat's re-read before readying and queueing.

② Semver level

minor on the three moved packages, declared BREAKING under the launch-window convention. Consistent with the changeset and with the narrowing: a published read accept set shrinks (two fields withheld from every non-platform reader on two objects; one object's free-text search set shrinks for every reader; the ledger copies of two objects mask two columns). No published schema, export, enum or stored shape changes; no ADR-0087 registration is owed. Clause-② arm: narrowing (spelling note R3).

③ Boundary flags

Implemented-by: claude/issue-24625-identity-mail-read-gate
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

Blocking items: none. Residuals R1-R3 above carry their fixes; R2 wants a card filed by the seat before or at landing.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 23:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 23:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit d117382 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24625-identity-mail-read-gate branch October 20, 2026 00:35
os-steve pushed a commit that referenced this pull request Oct 20, 2026
Conflicts and restatements, the ceremony being open on this branch:

- runtime boot guard, its unit, kernel and dogfood pins: #24682's 18.0
  "fresh installs only" refusal gives way to the marker-based refusal that
  names the ceremony's plan and apply.
- cli.mdx: the open-ceremony text kept; #24678 and step 4's "until then the
  ceremony refuses" sentences restated; #24722's audit-mail-bodies row and
  section kept beside it.
- B2: #24661's seven 18.0 upgrade-guide entries prescribe the ceremony again
  (the ups entry keeps the full apply), pinned in
  organization-ownership-upgrade-entries.test.ts.
- metadata-lifecycle, metadata-service, capabilities, permission-sets: the
  18.0 deferral restated.
- platform-core.v18-boot-refusals revision 5.
- objectql: WALLED_ORGANIZATION_LESS_BRIDGE, isWalledOrganizationLessBridgeObject
  and the engine reader are deleted (sys_secret and sys_flow_credential are
  column-less here); the refusal names systemFields: { tenant: false }. The
  tripwire is deleted; the flow-credential walled pin is kept, renamed
  walled-flow-credential-write.test.ts.
- #24688's principal x door matrix restated over a ceremonied fixture.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
os-steve pushed a commit that referenced this pull request Oct 20, 2026
…before the ceremony

The ceremony's sibling-moves step asks for the sibling logs this release
added (sys_identity_email, sys_platform_setting_audit) rather than creating
them, so the operator's sequence is a refused boot of this release first,
then the plan and its apply. The shared helper now takes that boot and
throws unless the ownership guard refused it; #24688's principal x door
matrix uses it (25 cases, green).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
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

Development

Successfully merging this pull request may close these issues.

measure(plugin-email): who can list sys_email rows that carry identity mail (reset and verification links) — sys_email declares no requiredPermissions

2 participants