Repository navigation
Commit 67e8792
feat(plugin-audit): sys_comment gains a writer-stamped object_name + record_id pair with a backfill, and the comment-thread read gate leafs over it (ADR-0139 E7) (#23881)
Fixes #23857
Clause-②: yes
ADR-0139 E7, comments half, by the ruling on #23732 (record
`6212058353`, batch #354 item 2: letter A on Q1, A on Q2, maintainer
「同意」). `sys_comment` gains a writer-stamped parent pair, and the
comment-thread read gate narrows by it with one `ReadableIds` leaf per
parent object, with no pre-scan. #23732 remains open; the seat handles
it as the ruling's item 6 says.
## Rollout (the changeset's first paragraph)
**Run `os migrate comment-parent-pair --apply` at once after
upgrading.** Until it runs, the comments written before the upgrade
carry no pair, and the gate answers them by the bounded thread probe it
always had: at most 2,000 candidate rows per read, fail closed. So a
broad comment list (one not scoped to a single record's thread) can omit
comments the reader may see, and its `total` agrees with the short list.
A record's own thread is unaffected. The door copies `os migrate
activity-changed-fields` (E6, PR #23814) one for one: `--database-url`,
`--apply`, `--yes`, `--force`, `--json`; dry run by default; idempotent.
Same measured difference as E6: the columns arrive with the upgraded
deployment's schema sync, which `--apply` performs itself, so a dry run
against a database no upgraded process has opened cannot read them and
exits 1.
## What changed
- **The pair** (`objects/sys-comment.object.ts`). `object_name` and
`record_id` are `Field.text`, `readonly`, not required, and indexed
together as `(object_name, record_id)`. `thread_id` stays the authored
key. There is no ADR-0087 conversion and no writer change.
- **One planner**, `planCommentParentPair` (`comment-access-hooks.ts`).
It parses the thread with `parseCommentThreadId`, the parse every
comment gate authorizes by. A thread that names no record gets `null` /
`null`.
- **Who can write the pair:**
- Create: a `beforeInsert` hook stamps every row, for every caller, the
system included, and overwrites any value the writer sent. The engine's
create-side readonly strip skips `sys_` objects, so this hook is the
guard, as it is for `is_edited`.
- The re-point rule: an update that names `thread_id` is re-stamped from
it.
- An update that names either column without a thread: the update stamp
re-derives the pair from the row's stored thread, for every caller, and
drops it when there is no stored row. It does not rely on the engine's
strip, which under `preserveAudit` (the REST import's
`treatAsHistorical`) keeps a readonly value. A multi-row update of this
kind over rows whose threads name different records is refused whole by
the stamp itself, not by the engine's key-set divergence check
(`VALIDATION_ERROR` / 400, nothing written), because one `SET` clause
cannot carry two pairs. Over rows sharing one thread, it stores that
thread's pair. Pinned on a real engine and through the REST import
(patch rounds 1–2, addenda `6216192076` and `6217048956`).
- The backfill's SYSTEM by-id update: the stamp re-derives its pair from
the thread the row holds at write time. That narrows the backfill's race
to the one engine call; it does not close it.
- Both hooks stamp only when the registered `sys_comment` declares the
pair. This is E6's `declaresActivityStampColumns` precedent, for
harnesses that register their own `sys_comment`.
- **The gate** (`installCommentReadVisibility`). It is now
`computeParentRecordFilter` from `parent-record-read-gate.ts`. That
function is imported, not edited, so no third copy of the D5
classification is written. It runs over the comment's pair with a
`commentThreadGate()` config:
- The parent set is every registered object except `sys_comment`. All of
`AUDIT_EXCLUDED_OBJECTS` is named, because a comment can hang off any
record. This is the same set `parseCommentThreadId` accepts.
- D5's three classes are routed to the bounded probe by name and up
front, never by catching a refusal. So are the two whole-read states: no
`canReadObject` (no leaf is written without it) and an engine that
cannot classify.
- Rows with no stamped pair get one more branch, ORed in: the old
bounded thread probe, restricted to `object_name IS NULL`. Its branch
carries that conjunct, so it never selects a stamped row. Rows the
backfill has not reached are probed, never leafed.
- A `sys_comment` that declares no pair keeps the thread probe over
every row, and the gate says so once.
- `next()` sits outside the gate's `try`, so a composition refusal (on
`driver-mongodb`, a 500) is the read's own failure (D3).
- `AuditPlugin` passes the security source.
- **Reactions are unchanged** (Q2, D5.2). They are recorded on #23857
with the reading member 1,763 of 2,300 and administrator `total` 2,000
of 2,601.
- **The backfill**, `backfillCommentParentPair`
(`comment-parent-pair-backfill.ts`, exported from `src/index.ts`):
- It works in E6's shape: a SYSTEM keyset walk on `object_name $null`,
then a single-id SYSTEM update of both columns.
- It updates under `preserveAudit` with the row's stored `updated_at`,
so no comment's `updated_at` moves. Measured through the door.
- A thread that names no record is counted as `unparented` and left
unstamped.
- A refused page read is counted, so the door exits 1.
- **The door** `os migrate comment-parent-pair`, registered in the
one-shot family table, the JSON-purity family and the absent-database
roster.
- **Docs and generated files:**
- `deployment/cli.mdx`: a section and a table row.
- `platform-checklist/areas/cli.json`: 17 → 18 subcommands.
- The plugin-audit i18n bundles, regenerated and translated.
- The tenant-audit census, regenerated: 226 → 227 write sites, from the
backfill's update. The hand-written prose figures follow.
- The checklist item `attachments-storage.read-inherits-parent-rls`,
revision 5, carries the two notes from `6206614195`.
Landing places: everything is in `plugin-audit`, the CLI door and its
three family registrations, dogfood, docs, and the generated files the
gates regenerate. None of `writeAudit`, `packages/spec`, `objectql`,
`core`, any driver, `parent-record-read-gate.ts`,
`migrate/organization-ownership*`, `apply.ts`, `docs/adr/**` or objectui
is touched.
## Evidence
Local readings at head `e499adaf6` (with `origin/main` `34b740f2d`
merged in), on the shared box, under `scripts/pm/os-verify-lock.sh`.
Patch rounds 1–2 (to `7d41db652`) change only the update stamp,
docblocks, the changeset and tests. The read gate is untouched. The dev
re-read these numbers at `7d41db652` (supplement `6217097662`), and they
are unchanged: real engine member 2,303 of 2,303 and administrator
`total` 2,605 of 2,605; the stack served 2,050 of 2,050. The rounds' own
gates, suites and ablations are in addenda `6216192076` and
`6217048956`.
- `@objectstack/plugin-audit`: tests 53 files / 863 passed; `typecheck`
exit 0 (test-typecheck ledger OK).
- `@objectstack/cli`: unit project 301 files / 4,496 passed; `typecheck`
exit 0.
- CLI integration, run locally because the diff touches it:
- the new door suite plus `data-commands.absent-database` and
`schema-migrate.one-shot-family`: 147 / 147;
- `json-stdout-purity.e2e` (nightly tier): 62 / 62.
- `@objectstack/dogfood` typecheck exit 0. Dogfood: the new pin plus the
eight suites that read `sys_comment` or share the gate (the E6 pin among
them): 9 files / 79 passed.
- **The bound case, before and after.** "Before" is ablation A0 below:
main's whole-read thread probe put back.
| reading | before | after |
|:--|--:|--:|
| real engine + `AuditPlugin` + SQLite
(`comment-parent-pair.integration`, 2,607 rows): member served / total,
of 2,303 visible | 1,766 / 1,766 | 2,303 / 2,303 |
| same: administrator `total`, of 2,605 | 2,000 | 2,605 |
| real `SecurityPlugin` + `AuditPlugin` stack at the HTTP door (dogfood,
4,151 comments, one thread each): member served, of 2,050 visible | 981
| 2,050 |
The administrator's newest-20 read is served 20 rows, with a total past
2 × 2,000. An unreadable parent's comments stay hidden: another member's
record, a vault object, a deleted record, and a thread naming no record.
The thread-scoped read of one of them answers 0 rows and `total` 0.
- **No SYSTEM-composed read or leaf.** On the real stack, no SYSTEM read
of `sys_comment` carries a leaf. The only SYSTEM reads are the two
bounded probes: `thread_id` over `object_name $null`, and the D5 pair
probe.
**Ablations.** Each negative pin was run red, then restored. Every
mutation went through `scripts/ablation-replace.mjs`, and every restore
was proven blob == HEAD with `git diff HEAD` empty. Unless noted, the
pins are the two plugin-audit suites, run from `src`.
| leg | mutation | red |
|:--|:--|:--|
| A0 | main's whole-read thread probe put back | src 7 of 17. Dogfood
through `dist`: marker present (`ablation-dist-preflight` exit 0), 4 of
5 red. Restore rebuilt and the marker proven absent |
| A1 | the stamped branch admits every stamped row | 11 |
| A2 | unstamped rows not probed | 5 |
| A3 | the unstamped probe's branch loses its `object_name IS NULL`
conjunct | 2 |
| A4 | a leaf written without `canReadObject` | 1 |
| A5 | the consumer catches the read's refusal into deny-all | 2 (unit
and real engine) |
| A6 | the stamp keeps a writer's own pair | 7 |
| A7 | the re-point not re-stamped | 2 |
| A8b | a D5.2 middleware-gated parent leafed (temporary mutation of
`planParentReads`, not committed) | 9 |
| A9 | a delegated caller leafed (same file, not committed) | 2 |
| A10b | a refused page read not counted | 1 |
| A11 | a thread naming no record stamped | 7 |
| A12 | an update naming the pair without a thread not re-derived (patch
round 1) | src 3. Dogfood through `dist`: marker present
(`ablation-dist-preflight` exit 0), 2 red. Restore rebuilt and the
marker proven absent |
| A13 | with no stored row, the writer's pair kept (patch round 1) | 1 |
| A14 | the multi-row pair-divergence refusal removed (patch round 2) |
2 (real engine and unit) |
Void legs, declared:
- The first A0 leg was a no-op: its replacement contained the anchor,
the tool refused it, nothing landed, and its readings are discarded.
- A8 and A10 were refused the same way first (the replacement text
already occurred), and were rerun as A8b and A10b.
- A4, A5 and A9 were rerun after a test-only restructure of the unit
stand-in, with the same results.
**Gates.**
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 103 commands at `e499adaf6`: 23
paths, 2,276 changed lines, under the 3,000 threshold. All 103 exit 0.
`--ran` reports 「103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN」.
- `pnpm check:adr-symbol-anchors` exits 0. ADR-0139's anchors
`comment-access-hooks.ts#READ_SCAN_LIMIT` and
`#installCommentReadVisibility` still resolve.
- The first union had three real reds, all fixed:
- the census prose figures;
- `check:engine-double-contract` and `check:objectql-double-limit`, both
on my unit stand-in. The stand-in was restructured; no ledger was
touched.
- Three exit-3 prerequisites (missing `dist`, a shallow clone) re-ran
green after a build and a fetch.
## Acceptance notes
- **No redaction and no query guard over the pair.** The pair is a parse
of `thread_id`, which every reader of a row is already served and may
already filter by, so it discloses nothing new. The pair gets no entry
in `parent-field-query-guard.ts`; that guard covers value-bearing
columns of the activity stream and the ledger, and `sys_comment` has no
such column.
- **A wider read.** An unpinned comment read builds one branch per
registered object: E5's activity cost, now on comments too. Every
comment read also runs one SYSTEM probe over `object_name IS NULL`.
After the backfill that probe finds nothing; until then it finds at most
2,000 rows.
- **Unparented threads.** A comment whose thread names no record keeps
`null` / `null` forever, so the unstamped probe always scans it. It
stays hidden, as before.
- **The comments checklist item.** The card asks for "the comments
item's revision" to carry the same two notes, but no checklist item
covers comment read inheritance: the comment-related items in
`records-forms.json` are about feeds and posting. So only the
attachments item was revised, and no item was invented.
- **Stale prose outside this card's surface.**
`parent-record-read-gate.ts`'s `no-object-admission` warning still cites
the activity stream's withheld-update re-read, and the comment gate now
prints it too. The file is #22788's, so it is not touched here. Carrier:
#22788.
_Seat edit · `domain:services` seat 2 ·
`session_018VqJPGA1NDNSCZsnecsChP` · 2026-10-16T13:10Z: "Who can write
the pair" bullets 3–4 and ablations A12–A13, per patch round 1 (addendum
`6216192076`)._
_Seat edit · `domain:services` seat 2 ·
`session_018VqJPGA1NDNSCZsnecsChP` · 2026-10-16T13:56Z: bullets 3–4
again, ablation A14 and the Evidence scope line, per patch round 2
(addendum `6217048956`)._
_Seat edit · `domain:services` seat 2 ·
`session_018VqJPGA1NDNSCZsnecsChP` · 2026-10-16T13:58Z: the Evidence
re-read at `7d41db652` and bullet 3's refusal source, per supplement
`6217097662`._
---
_Generated by [Claude
Code](https://claude.ai/code/session_01NX4Woa8XhvMs6xpqQpx8kK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e9301e8 commit 67e8792
23 files changed
Lines changed: 2431 additions & 50 deletions
File tree
- .changeset
- content/docs
- deployment
- permissions
- docs
- audits
- qa/platform-checklist/areas
- packages
- cli
- src
- commands/migrate
- utils
- test
- plugins/plugin-audit/src
- objects
- translations
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1194 | 1194 | | |
1195 | 1195 | | |
1196 | 1196 | | |
| 1197 | + | |
1197 | 1198 | | |
1198 | 1199 | | |
1199 | 1200 | | |
| |||
1499 | 1500 | | |
1500 | 1501 | | |
1501 | 1502 | | |
| 1503 | + | |
| 1504 | + | |
| 1505 | + | |
| 1506 | + | |
| 1507 | + | |
| 1508 | + | |
| 1509 | + | |
| 1510 | + | |
| 1511 | + | |
| 1512 | + | |
| 1513 | + | |
| 1514 | + | |
| 1515 | + | |
| 1516 | + | |
| 1517 | + | |
| 1518 | + | |
| 1519 | + | |
| 1520 | + | |
| 1521 | + | |
| 1522 | + | |
| 1523 | + | |
| 1524 | + | |
| 1525 | + | |
| 1526 | + | |
| 1527 | + | |
| 1528 | + | |
| 1529 | + | |
| 1530 | + | |
| 1531 | + | |
| 1532 | + | |
1502 | 1533 | | |
1503 | 1534 | | |
1504 | 1535 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
| 190 | + | |
191 | 191 | | |
192 | | - | |
193 | | - | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
201 | 201 | | |
202 | 202 | | |
203 | 203 | | |
204 | | - | |
| 204 | + | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
227 | | - | |
| 226 | + | |
| 227 | + | |
228 | 228 | | |
229 | | - | |
| 229 | + | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
| 236 | + | |
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
| 248 | + | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
305 | | - | |
| 304 | + | |
| 305 | + | |
306 | 306 | | |
307 | 307 | | |
308 | 308 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
| 97 | + | |
| 98 | + | |
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
| |||
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
| 120 | + | |
120 | 121 | | |
121 | 122 | | |
122 | 123 | | |
| |||
0 commit comments