Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/24693-audit-mail-bodies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/plugin-audit': minor
'@objectstack/cli': minor
---

feat(cli,plugin-audit): `os migrate audit-mail-bodies` masks the mail bodies that audit and activity copies written before the mail-body mask still hold in clear

**Run `os migrate audit-mail-bodies --apply` once after upgrading.** Since the mail-body read gate, the audit writer masks `body_text` and `body_html` whenever it copies a `sys_email` or `sys_identity_email` row into `sys_audit_log` (`new_value`, `old_value`) or `sys_activity` (both halves of `metadata`). Copies written before that still hold the body in clear at rest: password-reset, verification, magic-link and invitation links included. Today they are served only to a reader who is also served the body field, so this is no open door; it is a credential copy at rest, and it is what any later relaxation of the mail-body gate would re-open.

Clause-②: yes

- **The command.** `os migrate audit-mail-bodies` masks those copies with the writer's own mask: a set body becomes `••••••••`, an empty one stays empty, and a copy that never carried the column gains none. It is a dry run by default (counts, writes no row), `--apply` writes, and its flags are `os migrate audit-metadata-bodies`'s, one for one (`--database-url`, `--apply`, `--yes`, `--force`, `--json`). The report is counts only, per table, and never prints a value.
- **What changes in a row.** Only the body keys of the copy. Every other key of the copy and every other column of the row stay byte-identical, the row's `updated_at` included (the rewrite writes under `preserveAudit` with the stored stamp).
- **Idempotent.** A masked body is the mask, so a second run masks nothing; re-running finishes an interrupted run. A failed read or write exits `1`.
- **No selector, no ordering.** Every copy of both mail logs is masked, as the writer masks new ones, so the command needs no rule telling identity mail apart from tenant mail. It gives the same result before or after `os migrate organization-ownership`: that ceremony re-points moved mail's activity copies to `sys_identity_email`, which is masked the same, and leaves `sys_audit_log` as written.
- **Who sees a difference.** A platform administrator (the one reader the body field is served to) no longer reads a pre-upgrade mail body in the audit trail or the activity feed. The mail log row itself keeps its body, behind its field gate.

`@objectstack/plugin-audit` exports the rewrite, `migrateMailLogBodyCopies` (with `MAIL_LOG_BODY_COPY_OBJECTS`, `MAIL_LOG_BODY_MIGRATION_PAGE_SIZE` and its engine and report types), its two pure planners `planMailLogBodyAuditRowPatch` and `planMailLogBodyActivityRowPatch`, and `maskMailLogBodyCopy`, the one mask the audit writer and the rewrite both apply.
35 changes: 33 additions & 2 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,7 @@ where the data lives.
| `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column |
| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the command refuses. See [below](#os-migrate-organization-ownership) |
| `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent |
| `os migrate audit-mail-bodies` | Mask the mail bodies (`body_text`, `body_html`) that `sys_audit_log` and `sys_activity` copies of `sys_email` / `sys_identity_email` rows still hold in clear, written before the audit writer masked them. Reports counts only. No flag; idempotent. See [below](#os-migrate-audit-mail-bodies) |
| `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent |
| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
| `os migrate security-catalog-overlays` | List the environment-wide permission-set and position rows (the legacy plurals `permissions` / `positions` included) stored over a name a configured package holds — the rows a v18 cold boot refuses — each with the package that holds its name; `--apply` deletes them, one history tombstone per row. Run it before the first v18 boot, with the flags and environment the deployment boots with: the platform security plugin's sets are held only where `os serve` composes it (an auth secret set, and the `auth` tier on), and the step takes `serve`'s `--preset` and `--dev` with `serve`'s meaning. Adopts nothing; exits 1 while it lists a row |
Expand All @@ -1236,8 +1237,8 @@ holds nothing, and answers with empty work and exit 0. `os migrate value-shapes`
clean over zero records and names the objects it did not read. `os migrate
recorded-by` has nothing to convert and `os migrate resume` no interrupted runs.
`os migrate meta --stored` has no stored metadata to examine, `os migrate
audit-metadata-bodies` no audit copy to rewrite, and `os migrate account-issuer` no
account to collide. `os secret orphans`, `os secret rewrap` and `os storage orphans`
audit-metadata-bodies` and `os migrate audit-mail-bodies` no audit copy to rewrite, and
`os migrate account-issuer` no account to collide. `os secret orphans`, `os secret rewrap` and `os storage orphans`
report no secret and no file. A read the command cannot avoid and that fails for any
other reason still refuses and exits 1. Point `--database-url` at the deployment's
database, or boot the deployment once first, to see what it holds.
Expand Down Expand Up @@ -1488,6 +1489,36 @@ the upgraded deployment's schema sync, which an `--apply` run performs itself:
a dry run against a database no upgraded process has opened cannot read them,
reports the failed read and exits `1`. A failed read or write exits `1`.

#### `os migrate audit-mail-bodies`

The audit trail keeps a copy of each audited row: `sys_audit_log` in
`new_value` / `old_value`, and the activity feed in both halves of
`sys_activity.metadata`. For a mail-log row (`sys_email`, `sys_identity_email`)
that copy used to carry the mail **body**, so password-reset, verification,
magic-link and invitation links sat in the audit tables in clear. New copies
mask `body_text` and `body_html` the way a credential field is masked
(`••••••••`; an empty body stays empty). This command masks the copies written
before that.

```bash
os migrate audit-mail-bodies # Dry run: counts, writes no rows
os migrate audit-mail-bodies --apply # Mask the copies (prompts)
os migrate audit-mail-bodies --apply --yes --json # CI / scripts
```

Every copy of either mail log is masked; no mail is told apart from another, so
the command does not depend on `os migrate organization-ownership` and gives the
same result run before or after it. Only the body keys of the copy change: every
other key of the copy, and every other column of the row (its `updated_at`
included), stays as it was. The report holds counts only and never prints a
value.

**Idempotent**: a masked body is the mask, so a second run masks nothing, and
re-running is how an interrupted run is finished. A failed read or write exits
`1`. The copies age out with each table's retention (14 days for activity, 90
for the audit log, unless the deployment lengthens it); run it once after
upgrading rather than wait for that.

#### `os migrate organization-ownership`

The v18 ownership ceremony (ADR-0131 D10). A v18 `os serve` / `os start` refuses
Expand Down
30 changes: 15 additions & 15 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **50 of the 230 sites are spelled that way**. A
forwarding shim cannot, and **50 of the 231 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **230** |
| 175 write call sites | quoted in the merged changeset | **231** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 230** decidable, **74** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 129 decidably elevated, 0 decidably not, 93 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 231** decidable, **75** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 130 decidably elevated, 0 decidably not, 93 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -207,11 +207,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
129 of 230 (56%) as decidably elevated, with 93 more whose elevation is a
130 of 231 (56%) as decidably elevated, with 93 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `2 / 230`, and say what it is**: the sites whose options argument was
⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **29 further sites** have an options argument this
Expand All @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **230** |
| write call sites on the application surface | **231** |
| …whose object name is statically decidable | 156 |
| …whose object name is chosen at run time | 74 |
| …whose object name is chosen at run time | 75 |
| …against an object with tenancy ENABLED | 155 |
| …against an object that declares tenancy off | 1 |
| threading a tenant context | 172 |
| threading a tenant context | 173 |
| PROVABLY carrying none (options read, no context key) | **8** |
| …of those, against a decidably tenancy-enabled object | **2** |
| options argument UNREADABLE — may or may not carry one | 50 |
| …of those, against a decidably tenancy-enabled object | 29 |
| threading a decidably ELEVATED (`isSystem`) context | 129 |
| threading a decidably ELEVATED (`isSystem`) context | 130 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 93 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 189 |
| receiver carried a readable engine type | 190 |
| receiver erased, placed by the object NAME | 31 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 107 |
| object name spelled through a `const` | 49 |
| object name is an `object: string` parameter | 7 |
| object name is some other run-time expression | 67 |
| object name is some other run-time expression | 68 |

### Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-19 at `fa6a837e7`.
Measured on 2026-10-20 at `1f2b0cb73`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 672 |
| engine-shaped types recognised | 79 |
| tracked non-test sources scanned | 674 |
| engine-shaped types recognised | 80 |
| declared objects in the registry | 118 |
| same-named calls subtracted as non-engine | 166 |

Expand Down
15 changes: 8 additions & 7 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 230 |
| Write call sites | 231 |
| Object name statically decidable | 156 |
| Object name chosen at run time | 74 |
| Object name chosen at run time | 75 |
| Against a tenancy-enabled object | 155 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 172 |
| Threading a tenant context | 173 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 50 |
| …and decidably tenancy-enabled | 29 |
| Threading a decidably elevated context | 129 |
| Threading a decidably elevated context | 130 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 93 |

Expand Down Expand Up @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-19 at `fa6a837e7`.
Measured on 2026-10-20 at `1f2b0cb73`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 672 |
| engine-shaped types recognised | 79 |
| tracked non-test sources scanned | 674 |
| engine-shaped types recognised | 80 |
| declared objects in the registry | 118 |
| same-named calls subtracted as non-engine | 166 |

Expand All @@ -120,6 +120,7 @@ Measured on 2026-10-19 at `fa6a837e7`.
| `packages/plugins/plugin-audit/src/activity-changed-fields-backfill.ts` | `update` | `sys_activity` | enabled | elevated | 1 |
| `packages/plugins/plugin-audit/src/auth-event-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
| `packages/plugins/plugin-audit/src/comment-parent-pair-backfill.ts` | `update` | `sys_comment` | enabled | elevated | 1 |
| `packages/plugins/plugin-audit/src/mail-log-body-migration.ts` | `update` | `object` | undecidable | elevated | 1 |
| `packages/plugins/plugin-audit/src/read-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
| `packages/plugins/plugin-audit/src/stored-metadata-body-migration.ts` | `update` | `object` | undecidable | elevated | 1 |
| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
Expand Down
13 changes: 10 additions & 3 deletions docs/qa/platform-checklist/areas/cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
"title": "os migrate: bare command is a read-only plan, apply is safe-by-default, re-runs are idempotent, and --json exits 0 on success (#4873)",
"since": "v15",
"status": "active",
"revision": 5,
"revision": 6,
"priority": "P1",
"surface": "cli",
"personas": ["operator (local shell)"],
Expand Down Expand Up @@ -298,14 +298,15 @@
"security-catalog-overlays",
"comment-reactions",
"activity-changed-fields",
"comment-parent-pair"
"comment-parent-pair",
"audit-mail-bodies"
],
"traps": ["stale-dist"],
"automated": { "kind": "unit", "ref": "packages/cli/src/utils/format.exit-code.test.ts — covers acceptance[2] ONLY (#4873: emitJson / emitText leave the exit code alone on success and a duration can no longer reach the exit-code slot); acceptance[0], [1] and [3]–[6] need the run" },
"source": [
"packages/cli/src/commands/migrate/index.ts + plan.ts (#2186 bare-command-is-plan; #3917 enforced never-mutates; occupancy warning) + apply.ts (--allow-destructive / --force / --yes / in_sync)",
"packages/cli/src/utils/format.ts#CliExitCode (CliExitCode narrows the emitJson exit slot — the #4873 fix, commit 83df2fd)",
"packages/cli/src/commands/migrate/ (the 18 registered subcommands under `os migrate`, besides the bare command, enumerated as variants — oclif pattern strategy, one default-exported Command per non-test file)",
"packages/cli/src/commands/migrate/ (the 19 registered subcommands under `os migrate`, besides the bare command, enumerated as variants — oclif pattern strategy, one default-exported Command per non-test file)",
"sibling item cli.migrate-duplicates-inventory (the duplicates subcommand's own contract — cross-referenced, not duplicated)"
],
"history": [
Expand All @@ -328,6 +329,12 @@
"date": "2026-10-19",
"change": "steps[1] and automated.ref corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24551, cli.migrate-plan-apply-json step 2; round 2's Tier 1 record #24598, which names this ref among those that over-claim clause coverage; wave anchor #24579 §3). On an os-dev-synced DB the pending plan also lists the lifecycle-classed tables: at base 9a7838edf5 the one-shot boot provisions the telemetry sibling only under isDevelopmentBoot (packages/cli/src/utils/schema-migrate.ts) and resolveTelemetryDbPath declines on a non-dev boot without OS_TELEMETRY_DB. The one pin asserts only the #4873 exit-code contract, so the ref now says it covers acceptance[2] alone. No clause changed",
"ref": "#24649"
},
{
"revision": 6,
"date": "2026-10-20",
"change": "variants: audit-mail-bodies added, the 19th registered subcommand (#24693: the one-off mask of the mail bodies that sys_audit_log / sys_activity copies of sys_email and sys_identity_email rows still hold in clear). The source note's count moves from 18 to 19 with it. No clause changed",
"ref": "#24693"
}
]
},
Expand Down
Loading
Loading