Repository navigation
Commit dc93781
feat(plugin-audit): the platform stamps sys_comment is_edited / edited_at when a comment's body is edited (#23153)
Fixes #23124
Clause-②: no (narrowing) — it narrows a published accept set: a client's
write to `is_edited` / `edited_at` no longer takes effect. Per the
ruling, it owes one contract-review-tier review before the queue.
## What this does
Executes maintainer ruling A (recorded in comment 6137155820): the
platform, not the console, marks an edited comment.
- **The stamp.** `sys_comment` gets a second `beforeUpdate` handler,
`stampCommentBodyEdit` in `comment-access-hooks.ts`. It sets `is_edited:
true` and `edited_at` (server clock, ISO string) when the update's
`body` differs from the stored body (`ctx.previous.body`). An update
that does not name `body`, or sends the stored text again beside new
`mentions` (the console's shape), stamps nothing. It runs for every
caller, system included, the way the engine's `updated_at` stamp does.
Under `preserveAudit` (historical import) a supplied value is kept,
again as `updated_at` does. It is registered on its own, not inside the
authorization handler, because that handler returns early for system and
context-less callers and declares the whole-operation dispatch.
- **Read-only at the data door.** `is_edited` and `edited_at` declare
`readonly: true`.
- **Create.** The engine leaves `sys_` objects to their own guards on
create (`staticReadonlyInsertSubject`), so the comment create hook sets
both fields to "not edited" when a client sends them. This works the
same way as the `author_id` stamp next to it.
- **Docs in code.** The `sys_comment` docblock records the decision
(ADR-0049, declared = enforced), in the same voice as its two earlier
entries. The module header of `comment-access-hooks.ts` points to the
stamp.
## Mechanism hypotheses, measured
The rig: base `1ca7a805c`, a real kernel with ObjectQL, AuditPlugin and
the SQLite driver. The caller is the comment's author.
**H1 holds.** `git grep -n "is_edited\|edited_at"` outside the generated
translation bundles returns 2 hits, the two declarations in
`sys-comment.object.ts`. A body edit stored `is_edited: false` and
`edited_at: null`. The author's edit already passes the existing
authorization `beforeUpdate`.
**H2: what the engine does today, measured on the `created_at`
precedent.**
| write (as the author) | stored | reported |
|:--|:--|:--|
| update `{ body, created_at: forged }` | body written, `created_at`
unchanged | `onFieldsDropped` reported `{ fields: [created_at], reason:
readonly }`. This is the REST PATCH's `droppedFields`, plus a `warn`
line |
| the same, with `strictReadonlyWrites: true` | nothing |
`ERR_READONLY_FIELD_REJECTED` thrown. This is an in-process opt-in, and
no transport passes it |
| create `{ created_at: forged }` | the server time (the engine's audit
stamp overwrites the value) | nothing |
| update `{ is_edited: true, edited_at: forged }`, before this change |
both stored | nothing |
| create carrying both, before this change | both stored | nothing |
**Choice: (a) and (b) together.** On update, `readonly: true` gives the
engine's strip-and-report, and the stamp's overwrite wins on an edit. On
create, the comment create hook overwrites. There is no `packages/spec`
change.
There is no loud refusal, for two reasons:
- The engine does not offer one at the data door. Its strict mode is an
in-process opt-in on a single call.
- A refusal written in the comment hook would reject the edit the
released console makes. The console at the pinned `.objectui-sha`
(`1fb1ced`) and on objectui main (`5d3f437`) sends `is_edited: true,
edited_at: CLIENT_CLOCK` with every edit (`handleEditComment` in
`RecordDetailView.tsx`). The ruling files the console's removal only
once this release is installable, so a refusal would break comment
editing in the shipped console for that whole interval.
The strip is not silent at the REST door: it shows up in `droppedFields`
and in a `warn` line. The one silent case is a client value sent
together with a body edit. There the stamp replaces it with the true
value, the same shape as `created_at` / `updated_at`.
**H3: no bypass.** A `multi: true` predicate update dispatches
`beforeUpdate` once per matched row, with `input.id` and `previous`
bound. Measured: 2 matched rows gave 2 dispatches, each carrying its own
row's prior body. What that means for the stamp, all pinned:
- If every row's body changes, every row is stamped.
- If no row's body changes, no row is stamped.
- If the rows disagree, the engine refuses the whole batch
(`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, 400, nothing written). One SET
clause cannot mark some rows and not others.
Other doors checked:
- The engine has no upsert path; `update.options.upsert` is a retired
tombstone.
- `insert` / `insertMany` dispatch `beforeInsert` once per row.
- REST batch, protocol `updateData` / `updateManyData`, the import
runner and the automation CRUD nodes all go through `engine.update`.
- The only door that reaches a table without hooks is raw
`engine.execute()`, a driver statement. It bypasses every hook and every
strip for every object by design, no transport exposes it, and its only
callers in the tree are migrations. No caller writes `sys_comment`
through it.
So there is no fork.
## Pins
`comment-edit-stamp.integration.test.ts` has 10 tests on the real
engine:
1. An author edit of the body stamps both fields from the server clock.
2. A `mentions`-only edit does not stamp, with or without the unchanged
body sent beside it.
3. A create does not stamp, and a create carrying `is_edited: true,
edited_at: forged` stores "not edited".
4. A body edit carrying `is_edited: false, edited_at: forged` is stamped
`true` with the server clock.
5. With no edit, `{ is_edited: true, edited_at: forged }` and later `{
is_edited: false, edited_at: null }` change nothing. Each is reported as
`{ reason: readonly, fields: [is_edited, edited_at] }`.
6. A predicate update where every body changes stamps every row.
7. A predicate update where no body changes stamps no row.
8. A predicate update whose rows disagree is refused: `mapDataError`
gives 400 `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, the error's `keys` are
`[edited_at, is_edited]`, and nothing is written.
9. A system-context body edit is stamped.
10. A `preserveAudit` update keeps the mark it supplies.
**Run against the base's implementation** (both source files restored
from `1ca7a805c`, test file unchanged): `Tests 7 failed | 3 passed
(10)`. The 3 that pass are 2, 7 and 10. They pass there by construction,
because nothing was stamped and nothing was read-only, so they carry no
weight alone. The no-stamp pins 2 and 7 are held by the ablations below.
## Ablations
Each ran through `scripts/ablation-replace.mjs` in wrap mode, after the
implementation was committed. The anchor hit was verified on disk, and
each restore was proven: blob equal to HEAD and `git diff HEAD` empty.
| # | forbidden behaviour put back | red |
|:--|:--|:--|
| A1 | delete the stamp's `body`-present guard | pin 2 (`an update that
does not name body stamped the comment`), pin 5 |
| A2 | delete the unchanged-body comparison | pin 2 (`an update
re-sending the stored body stamped the comment`), pin 7 (`unchanged row
… was stamped`), pin 8 (`a mixed predicate update was accepted`) |
| A3 | both fields declared `readonly: false` | pin 5 (`a client marked
an unedited comment edited: expected { is_edited: true, …(1) } to deeply
equal { is_edited: false, edited_at: null }`) |
| A4 | the stamp prefers the client's submission (`ctx.submitted.* ??
stamp`) | pin 4 (`the client cleared the mark on its own edit: expected
false to be true`) |
| A5 | delete the create-side overwrite | pin 3 (`a client-supplied mark
landed on create`) |
A3's first attempt used a replacement that was a substring of its
anchor. The tool refused it (the replacement count could not rise), so
it never ran. It was re-run with `readonly: false` and went red as
shown.
## Tests
All on HEAD `67900682e2`:
- `pnpm --filter @objectstack/plugin-audit test`: `Test Files 48 passed
(48)`, `Tests 771 passed (771)`.
- `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, including
`check:test-typecheck: OK`. `tsc --listFiles` over `tsconfig.test.json`
lists both edited test files.
- `pnpm --filter @objectstack/plugin-audit build`: exit 0.
## Gates
The gate list comes from `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`, run with no paths at HEAD
`67900682e2`. It gives 67 commands, the same as the PM's list for tree
`1ca7a805c`. All 67 were run at `67900682e2`:
- **66 exit 0.**
- `pnpm check:i18n` first exited 3 (PREREQUISITE NOT MET: the CLI was
not built). After the gate's own one-command prerequisite build it reads
`check-i18n-bundles: OK (10 package(s) — all bundles in sync, no
undeclared authoring keys)`, and plugin-audit's 7 bundles are in sync.
- `pnpm check:dual-build-cjs-loads` is **NOT MEASURED**. It exited 3,
PREREQUISITE NOT MET, because it reads every package's built output and
54 packages have no `dist/` in this worktree. It needs a whole-repo
`pnpm build`, so it is left to CI. plugin-audit's own build passes
`check-dts-emitted`, and its exports are unchanged.
`dispatch-gates --ran`, with an exit code recorded for every command: 67
derived, 66 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0
UNRUN.
Verdict lines quoted from the gates:
- `check-adr-0087-registration`: `✓ 1 declared-breaking changeset(s),
each carrying an ADR-0087 disposition`, reading
`[BREAKING+clause-②-narrowing] not-required
(no-migration-prescription)`.
- `check-changeset-no-major`: `✓ This diff introduces no major bump`.
- `check-empty-changeset`: `✓ No empty-frontmatter changeset introduced
by this diff`.
- `check:nul-bytes`: `OK … no raw ASCII control bytes`.
- `check:test-source-alias`: `OK`.
**Lint, narrowed and stated as a measurement:**
- `pnpm exec eslint --no-inline-config --format json` on the 4 changed
`.ts` files: 4 files, 0 errors, 0 warnings.
- Each file is inside eslint's own population (`ESLint.isPathIgnored`
returns false for all 4).
- `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict of any
file it does not touch.
- The full `pnpm lint` run belongs to CI.
## Acceptance notes
- **File surface.** As claimed: the hooks file, the object file, their
tests and one changeset. `audit-plugin.ts` is untouched, and no
translation bundle changed, since no label or description changed. Two
edits to the existing `comment-access-hooks.test.ts` are harness
changes, not new pins. Its fake engine kept the last handler per event,
so the stamp's registration would have replaced the authorization
handler those tests drive. The harness now keeps the first handler per
event. Its wired `sys_comment` fixture now declares the two fields the
stamp writes, because the engine refuses undeclared keys.
- **Clause-② arm.** The claim's `Clause-②` line, copied above as
written, carries no direction arm, but its reasoning names a narrowing.
The changeset declares `Clause-②: no (narrowing)` together with the
**BREAKING** banner and an ADR-0087 `not-required
(no-migration-prescription)` marker, and `check-adr-0087-registration`
reads it as `[BREAKING+clause-②-narrowing]`. If line 2 of this body
should carry the arm as well, the edit is to insert ` (narrowing)` after
`no`.
- **Changeset level.** `minor`, under the launch-window convention for
accept-set narrowings. The contract review judges the level.
- **objectui half.** Not touched here. Once this release is installable,
the console's two-line write in `handleEditComment` becomes inert: its
values are stamped over on an edit and stripped otherwise.
- **Observation, not filed.** `mapDataError` maps a
`ReadonlyFieldRejectedError` (the `strictReadonlyWrites` refusal, which
carries no `status`) to `404 OBJECT_NOT_FOUND`. This was measured
in-process only. No transport passes `strictReadonlyWrites`, so nothing
public reaches it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 02bec6c commit dc93781
5 files changed
Lines changed: 416 additions & 1 deletion
File tree
- .changeset
- packages/plugins/plugin-audit/src
- objects
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
74 | 77 | | |
75 | | - | |
| 78 | + | |
76 | 79 | | |
77 | 80 | | |
78 | 81 | | |
| |||
644 | 647 | | |
645 | 648 | | |
646 | 649 | | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
647 | 654 | | |
648 | 655 | | |
649 | 656 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
42 | 46 | | |
43 | 47 | | |
44 | 48 | | |
| |||
319 | 323 | | |
320 | 324 | | |
321 | 325 | | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
322 | 379 | | |
323 | 380 | | |
324 | 381 | | |
| |||
539 | 596 | | |
540 | 597 | | |
541 | 598 | | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
542 | 607 | | |
543 | 608 | | |
544 | 609 | | |
| |||
729 | 794 | | |
730 | 795 | | |
731 | 796 | | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
732 | 804 | | |
733 | 805 | | |
734 | 806 | | |
| |||
0 commit comments