Repository navigation
Commit 8bd0fcd
fix(plugin-audit): a fired milestone row carries metadata.kind 'milestone' (ADR-0052 §5), served to a reader served its watched fields (#22783)
Fixes #22771
Clause-②: no
## What changed
- `packages/plugins/plugin-audit/src/audit-writers.ts`: the
`activityMilestones` branch of the CRUD mirror writes `metadata.kind:
'milestone'` on the row it writes. `type` stays what the declaration
names: `completed`, or `updated` when it names none. ADR-0052 §5 puts
the kind in this slot: "`type` stays domain-NEUTRAL … Domain kind rides
in `metadata.kind`." No new `type` value, no new column, no
`packages/spec` change.
- The same row records where the kind came from.
`metadata.text_sources.kind` lists every field that a declared milestone
watches. The first match wins, so "a milestone fired" is a fact about
all of those fields, not only the one that fired.
- `packages/plugins/plugin-audit/src/activity-field-redaction.ts`:
`redactActivityRows` keeps `metadata.kind` for a reader served every
listed field, and drops it for a reader who is not. A kind on a row the
mirror did not write belongs to its author and is served as written.
Text columns get the same treatment today.
- A `@objectstack/plugin-audit` patch changeset.
## Measured on `origin/main` e84aeb3 before writing
| PM assumption | Reading |
|---|---|
| 1a. The milestone branch sets `activityType = milestone.type` and
records no provenance | **Holds.** `audit-writers.ts` has `if
(milestone.type) activityType = milestone.type;`. The row carries
`text_sources`, but only for the interpolated tokens and the label.
Nothing marks it as a milestone. |
| 1b. `metadata.text_sources` is written by the CRUD mirror only |
**Holds, with one precision.** The milestone branch is part of the CRUD
mirror (same `writeAudit`). So every mirror row carries `text_sources`,
milestone or not, and it cannot tell them apart. |
| 1c. `redactActivityRows` deletes `text_sources` before serving |
**Holds.** |
| The card's "the redaction keeps `metadata.kind`" needs a code change |
**Falsified.** On main the redaction already serves any top-level
`metadata` key other than `text_sources` untouched: it narrows only
`old`/`new`. The "keep" half needed no code. The code added is the
opposite direction (assumption 3). |
| 2. No `sys_activity` writer already uses `metadata.kind`, and no
`kind` vocabulary is declared | **Holds.** `git grep "metadata\.kind"`
outside docs: 0 hits. The only hit is ADR-0052 line 201. Control on the
same subject and failure shape: `metadata\.old` or `metadata\.new` in
`packages/**/*.ts`: 8 hits. The repo's only `sys_activity` insert is
`persistAuditTrailRow` (`audit-writers.ts`), called by the CRUD mirror
alone. `auth-event-audit` and `read-audit` write `sys_audit_log` only.
Comments write `sys_comment`. `packages/spec` declares
`SYS_ACTIVITY_BUILTIN_TYPES` and `FeedItemType` for `type`, and nothing
for `metadata.kind`. App writers in other repositories (hotcrm) cannot
be grepped here, and their kinds stay served as written. |
| 3. Keeping `metadata.kind` exposes a classification only, never field
content | **Not unconditionally.** `kind: 'milestone'` means "a watched
field just entered its declared value". To a reader not served that
field, that is field content. So the kind is judged like a text: it is
kept only for a reader served every watched field. |
## Keys the redaction keeps, before and after
These are the top-level `metadata` keys a reader with a security answer
is served on a CRUD-mirror row. Fail-closed paths are unchanged: they
drop the whole `metadata` column. The no-answer path is unchanged: it
passes the row whole.
| key | before | after |
|---|---|---|
| `old` / `new` | kept; each key the reader is not served is dropped |
unchanged |
| `text_sources` | dropped, never served | unchanged |
| `kind` (milestone rows only) | not written | kept when every field in
`text_sources.kind` is served; dropped otherwise |
| any other key, on a row an app wrote | served as written | unchanged |
## Tests (HEAD `f5281e88b`)
New file: `src/activity-milestone-kind.integration.test.ts`, 10 tests.
It uses a real `ObjectKernel`, the real SQLite driver and the real
`AuditPlugin`. Rows are written by the real CRUD mirror and read through
the middleware chain the plugin mounts: the console's activity read
(`find` on `sys_activity` by `object_name` + `record_id`) and the by-id
read, as non-system callers. The only stand-in is the security service.
- A milestone row is served with `metadata.kind: 'milestone'`, through
both the list read and the by-id read. This holds for a typed milestone
(`completed`) and an untyped one (`updated`).
- CONTROL: CRUD-mirror rows that no milestone fired (create, tracked
update) carry no kind, at rest or served.
- CONTROL, key for key: the whole served `metadata` of a milestone row
is pinned per reader with `toEqual`. `text_sources` is never served, a
withheld change key is dropped from both sides, and `kind` is the only
key judged by its declared fields.
- A reader withheld a watched field gets both milestone rows without the
kind. This includes the untyped one, whose own field that reader is
served.
- A reader withheld a field that no milestone watches still gets the
kind.
- A kind on an app-written row is served as written to every reader.
Runs (under `scripts/pm/os-verify-lock.sh`):
- `pnpm --filter @objectstack/plugin-audit exec vitest run
--maxWorkers=2`: Test Files 46 passed (46), Tests 726 passed (726), at
`0655363f8`. The only later commit, `f5281e88b`, is a type annotation in
the new test. The new file re-ran at `f5281e88b`: 10 passed.
- `pnpm --filter @objectstack/plugin-audit typecheck`: VERDICT
command-exit 0 at `f5281e88b`. `check:test-typecheck: OK … 0 file(s) / 0
error(s)` in debt. The first run had 2 TS2345 errors in the new test
(`Row | null` passed into the metadata reader); `f5281e88b` fixed them.
## Ablations (committed state `2f5b81f3e`; each leg went through
`scripts/ablation-replace.mjs` with its own restore)
The suite resolves the subject through relative `src/` imports, not
through a package `exports`, so no `dist/` rebuild is needed per leg.
Each leg's anchor hit 1 time before and 0 times after, and the blob
changed. Each restore was proven by blob equal to HEAD and an empty `git
diff HEAD`. The tree was clean afterwards.
| leg | mutation | result |
|---|---|---|
| writer-no-stamp | the milestone branch no longer sets the kind | 5
failed / 5 passed: at-rest kind, control served, note-withheld served,
by-id, key-for-key |
| redaction-deletes-kind (the PM's "put the deletion back") | the
redaction deletes `kind` for every row | 5 failed / 5 passed: control
served, note-withheld, by-id, key-for-key, app-written kind |
| redaction-no-gate | the redaction never drops `kind` | 3 failed / 7
passed: watch-withheld reader, by-id, key-for-key |
| writer-fired-only | `text_sources.kind` lists only the fired
milestone's field | 2 failed / 8 passed: at-rest declared fields, and
the watch-withheld reader on the untyped milestone |
## Gates (HEAD `f5281e88b`)
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 67 commands. All 67 were run, and
each exit code was recorded before any pipe.
- 65 exited 0 on the first pass.
- `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n` first exited 3
(PREREQUISITE NOT MET: no `dist/`). They exited 0 once
`check:type-check-debt` had built the workspace (i18n: "OK (9 package(s)
— all bundles in sync").
- `--ran` reconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN".
Lint was narrowed, and the narrowing is measured. `pnpm exec eslint
--no-inline-config --format json` on the 3 changed `.ts` files gave 3
files in the JSON, 0 errors and 0 warnings. These are the diff's whole
TypeScript population; the fourth path is a `.changeset` Markdown file,
which no `files` glob in `eslint.config.mjs` matches.
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so this diff cannot change the verdict on any
file it does not touch.
## Acceptance notes
- **Pre-existing, outside this card (for triage).** A reader withheld a
milestone's watched field is still served that milestone row's `type`,
which comes from the declaration, and its `summary`. The summary keeps
the template's literal wording, and `text_sources.summary` names only
the interpolated tokens, never the watched field. Probe at `2f5b81f3e`,
through the same served read, as a reader withheld `stage`: `{type:
'completed', summary: 'Won item'}`, with `stage` redacted out of
`metadata`. Both columns state that the withheld field entered `won`.
This PR's `kind` is gated, so it adds nothing to that disclosure. The
type and summary halves are reported for the seat to route.
- The objectui#12106 reader that consumes the kind sees it only for a
reader served every watched field. A reader who is not served one of
them keeps today's behaviour, by design.
- Rows written before this change carry no kind and are not backfilled.
The 14-day activity retention ages them out.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c1b7f2b commit 8bd0fcd
4 files changed
Lines changed: 398 additions & 13 deletions
File tree
- .changeset
- packages/plugins/plugin-audit/src
| 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 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
51 | 60 | | |
52 | 61 | | |
53 | 62 | | |
| |||
142 | 151 | | |
143 | 152 | | |
144 | 153 | | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
145 | 167 | | |
146 | 168 | | |
147 | 169 | | |
148 | 170 | | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
149 | 178 | | |
150 | 179 | | |
151 | 180 | | |
| |||
197 | 226 | | |
198 | 227 | | |
199 | 228 | | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
200 | 243 | | |
201 | 244 | | |
202 | 245 | | |
| |||
355 | 398 | | |
356 | 399 | | |
357 | 400 | | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
358 | 405 | | |
359 | 406 | | |
360 | 407 | | |
361 | 408 | | |
362 | 409 | | |
363 | 410 | | |
364 | 411 | | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
365 | 416 | | |
366 | 417 | | |
367 | 418 | | |
| |||
372 | 423 | | |
373 | 424 | | |
374 | 425 | | |
375 | | - | |
376 | | - | |
377 | | - | |
378 | | - | |
379 | | - | |
380 | | - | |
| 426 | + | |
381 | 427 | | |
382 | 428 | | |
383 | 429 | | |
| |||
0 commit comments