Repository navigation
fix(plugin-audit): a fired milestone row carries metadata.kind 'milestone' (ADR-0052 §5), served to a reader served its watched fields - #22783
Conversation
… (ADR-0052 §5), served to a reader served the watched fields The activityMilestones branch of the CRUD mirror keeps the row's type as its declaration names it and records the domain kind in metadata.kind, declaring the watched fields it derives from in text_sources.kind. The served-row redaction keeps the kind for a reader served those fields and drops it for one who is not, beside the text-column rule it already applies. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…e served activity read Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…r a by-id null Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f0f4f64f90509fea950dffe1ce86e3c1073e1d65 && git checkout f0f4f64f90509fea950dffe1ce86e3c1073e1d65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e84aeb36ce14169a633670f14ce8280fc998e2b9 f5281e88b9752d85530d63d084d69dde80c2787a && git checkout -B drift-repro e84aeb36ce14169a633670f14ce8280fc998e2b9 && git merge --no-ff f5281e88b9752d85530d63d084d69dde80c2787a
node scripts/docs-audit/affected-docs.mjs --json e84aeb36ce14169a633670f14ce8280fc998e2b9 |
…mpleted task (objectui#12106) (objectstack-ai#12137) Fixes objectstack-ai#12106 Clause-②: no ## What changes A `sys_activity` row of type `completed` that an object's `activityMilestones` entry fired ("Case resolved", "Case closed", a task's own "Task completed") is no longer read as a completed task. plugin-audit stamps such a row with `metadata.kind: 'milestone'` (objectstack-ai/objectstack#22771, landed by objectstack-ai/objectstack#22783 as `8bd0fcd07f`). `activityRowToFeedItem` now reads that marker on a row that would land on a feed type `showCompleted` hides, and maps it to `system`. A row without the marker maps exactly as before. This is route B from the card's triage ruling (comment `6105608877`), under the unlock's landing condition (comment `6106890335`): an unmarked `completed` row behaves as today, hidden under `showCompleted`'s default. ## Both readers One change covers both console reads of `sys_activity`, because both hand raw, unprojected rows to this one mapper: - the `record:activity` block's self-fetch (`record-activity.tsx`, its `dataSource.find('sys_activity', …)`); - the console record page's merged feed (`RecordDetailView`, its `sys_activity` read), which feeds the `record:discussion` block on the synthesized default page. That reader calling the exported mapper is already pinned by `RecordDetailView.activityUnmappedType-5896.test.tsx` ("IDENTITY — the merge builds its items with the exported `activityRowToFeedItem`"). ## Why `system`, and not `task` with a `showCompleted` exemption Measured against every reader of `FeedItem.type` in the tree: | reader | `system` (this PR) | `task` plus an exemption | | --- | --- | --- | | `filterMode: 'tasks_only'` (`RecordActivityTimeline`, `filterItems`) | a milestone is not listed as a task | "Case resolved" listed as a task | | `types: ['task']` allow-list (`applyFeedConfig`) | tasks only | also selects milestones | | `showCompleted` (`COMPLETED_FEED_TYPES`) | stays exact: `task` is produced only by completed activities | needs a per-item exemption | | icon and colour (`FEED_TYPE_ICONS`, `FEED_TYPE_COLORS`) | what every other author-typed milestone already gets (`escalated` falls to `system`), and what app-shell's `ActivityItem` surface gives `completed` (`activityItemType.ts`) | check-square, green | | `unifiedTimeline: false` | kept | kept | | what must be carried past the mapper | nothing: the type is decided where the raw row is in hand | a new `FeedItem` member in `@object-ui/types`, set by every host feed | The exemption route would also move a published type (`FeedItem`), which `Clause-②: no` rules out. The cost of `system`: on a page with `showCompleted: true`, a milestone `completed` row that used to draw with the task icon now draws with the system icon. ## The served shape `sys_activity.metadata` is a `textarea` column described as "JSON-serialized additional context". The writer stores it with `safeStringify`, the redaction re-serialises it, and the writer's own test (`activity-milestone-kind.integration.test.ts`) reads the served row with `JSON.parse(r.metadata)`. So the reader parses that JSON string and reads nothing else. An already-parsed object is not read as the marker: no served row carries one, and reading both would be a second dialect (AGENTS.md #0.1). A string that is not a JSON object, or a `kind` other than `'milestone'` (an app may write `kind: 'email'`), is "no marker". ## Docs - `content/docs/plugins/plugin-detail.mdx`: a new paragraph after the mapping paragraph says what a milestone row does, and the `showCompleted` input row and the produced-kinds table say the same. The props listing's `showCompleted` comment now reads "Include completed activities" (the spec's description) instead of "Show completed tasks". The new paragraph sits outside the anchored mapping paragraph, so `docsActivityMapTranscription-7876.test.ts` still reads exactly the map's transcription. - `packages/plugin-detail/src/index.tsx`: the block registration's `showCompleted` input description gains one sentence saying a milestone row shows either way. This change made that text false for milestone rows, so it is corrected here. No other open PR touches the file (read while writing this, with a control leg). - `packages/plugin-detail/README.md` names `showCompleted` but does not describe the mapping, so it is unchanged. ## Pins New file `recordActivityFeed.milestoneKind-12106.test.tsx`, 16 tests: - a milestone row is shown under `showCompleted`'s default (`record:discussion`, the default page's block); - CONTROL: a `completed` row with no marker is hidden by default, as today (the v17 face), on `record:discussion` and `record:activity`; - CONTROL: `showCompleted: true` shows both; - a task's own completion milestone, on that task's own page, is shown; - the mapping itself: a marked row maps to `system`; six CONTROL shapes still map to `task` (no `metadata`, the change without a `kind`, an app's own `kind`, non-JSON, a JSON array, an already-parsed object); the marker moves no `updated`, `scheduled` or `system` row; `types: ['task']` still selects tasks only; - one DOM pin through the `record:activity` self-fetch: "Case resolved" renders and the unmarked completion does not. ### Ablation, both directions (from committed `0ef756b`, each restore proven) Driven by objectstack's `scripts/ablation-replace.mjs` in wrap mode (the anchor must hit once, the blob must change, the restore is proven by blob equal to HEAD and an empty `git diff HEAD`), inside a bash EXIT/INT/TERM trap that repeats the same proof. The test imports the module by relative path, so the run reads `src` and no `dist/` rebuild is involved. 1. Marker read removed (`isMilestoneActivityRow(row)` replaced by `false`): 7 failed, 9 passed. The 7 are the milestone pins, as predicted. Restored: blob `25b2756a8306` equals HEAD, `git diff HEAD` empty. 2. Over-broad reader (every `completed` row treated as a milestone): 10 failed, 6 passed. The 10 are every CONTROL, the `types: ['task']` pin and the DOM pin. Restored the same way. ## Verification on final HEAD `9c5648d` - plugin-detail's dependency closure: `turbo run build --filter='@object-ui/plugin-detail^...' --concurrency=2`, 11 of 11 tasks (restored from turbo's input-hashed cache). - `pnpm --filter @object-ui/plugin-detail type-check` (echoed `type-check`, ran `tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFiles` shows the test program compiles the new file. - `pnpm exec vitest run packages/plugin-detail/` in 4 shards: 252 files passed and 1 skipped (253, the tracked count); 2459 tests passed, 8 skipped. - The app-shell and console tests that read the mapper, plus the two ratchets (`column-identity.ratchet.test.ts`, `one-authority-per-exported-name-6273.test.ts`): 8 files, 306 tests passed. - `pnpm exec vitest run scripts/__tests__/` in 4 shards: 179 files passed, 2 skipped (181). - Exit 0: `check-changeset-presence`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:new-line-citations`, `check:control-bytes`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:phantom-deps`, `changeset:check`, `check:doc-types`, `check:doc-fences`, `docs:check-links`. - eslint on the 3 touched source files: 0 errors. `index.tsx` has 32 warnings, the same 32 as its BASE blob; the other two files have 0. This narrowed run is a measurement: the config has no type-aware linting and no custom rule in `eslint-rules/` reads another file, so this diff cannot move the verdict on an untouched file. ## Acceptance notes - NOT MEASURED: `check:doc-snippets`. Reason: PRECONDITION NOT MET (exit 2), it needs a 34-package build. It compiles only `ts`, `tsx` and `typescript` fences, and this diff's one fenced edit is inside a `plaintext` block. - NOT MEASURED: the eager-closure byte delta. Reason: it needs a console build at base and at head, outside the local gate scope. CI's Bundle Analysis reads it. - NOT MEASURED: an HTTP readback of a served milestone row. Reason: the shared objectstack checkout in this container predates the writer. The served shape is read from the writer's own pinned test instead. - The server leaves the marker off for a reader it does not serve a field the milestone watches (the writer's redaction rule), so for that reader the milestone stays hidden by default. The docs paragraph says so. - Cloud runs the v17 framework until objectstack-ai/cloud#2709, so every row there arrives without the marker and behaves as today. Seat `domain:ui#2`, session `session_01TYgwmFK1q4KJ6Qq2WRLzsD`. --- _Generated by [Claude Code](https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
Fixes #22771
Clause-②: no
What changed
packages/plugins/plugin-audit/src/audit-writers.ts: theactivityMilestonesbranch of the CRUD mirror writesmetadata.kind: 'milestone'on the row it writes.typestays what the declaration names:completed, orupdatedwhen it names none. ADR-0052 §5 puts the kind in this slot: "typestays domain-NEUTRAL … Domain kind rides inmetadata.kind." No newtypevalue, no new column, nopackages/specchange.metadata.text_sources.kindlists 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:redactActivityRowskeepsmetadata.kindfor 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.@objectstack/plugin-auditpatch changeset.Measured on
origin/maine84aeb3 before writingactivityType = milestone.typeand records no provenanceaudit-writers.tshasif (milestone.type) activityType = milestone.type;. The row carriestext_sources, but only for the interpolated tokens and the label. Nothing marks it as a milestone.metadata.text_sourcesis written by the CRUD mirror onlywriteAudit). So every mirror row carriestext_sources, milestone or not, and it cannot tell them apart.redactActivityRowsdeletestext_sourcesbefore servingmetadata.kind" needs a code changemetadatakey other thantext_sourcesuntouched: it narrows onlyold/new. The "keep" half needed no code. The code added is the opposite direction (assumption 3).sys_activitywriter already usesmetadata.kind, and nokindvocabulary is declaredgit grep "metadata\.kind"outside docs: 0 hits. The only hit is ADR-0052 line 201. Control on the same subject and failure shape:metadata\.oldormetadata\.newinpackages/**/*.ts: 8 hits. The repo's onlysys_activityinsert ispersistAuditTrailRow(audit-writers.ts), called by the CRUD mirror alone.auth-event-auditandread-auditwritesys_audit_logonly. Comments writesys_comment.packages/specdeclaresSYS_ACTIVITY_BUILTIN_TYPESandFeedItemTypefortype, and nothing formetadata.kind. App writers in other repositories (hotcrm) cannot be grepped here, and their kinds stay served as written.metadata.kindexposes a classification only, never field contentkind: '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
metadatakeys a reader with a security answer is served on a CRUD-mirror row. Fail-closed paths are unchanged: they drop the wholemetadatacolumn. The no-answer path is unchanged: it passes the row whole.old/newtext_sourceskind(milestone rows only)text_sources.kindis served; dropped otherwiseTests (HEAD
f5281e88b)New file:
src/activity-milestone-kind.integration.test.ts, 10 tests. It uses a realObjectKernel, the real SQLite driver and the realAuditPlugin. Rows are written by the real CRUD mirror and read through the middleware chain the plugin mounts: the console's activity read (findonsys_activitybyobject_name+record_id) and the by-id read, as non-system callers. The only stand-in is the security service.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).metadataof a milestone row is pinned per reader withtoEqual.text_sourcesis never served, a withheld change key is dropped from both sides, andkindis the only key judged by its declared fields.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), at0655363f8. The only later commit,f5281e88b, is a type annotation in the new test. The new file re-ran atf5281e88b: 10 passed.pnpm --filter @objectstack/plugin-audit typecheck: VERDICT command-exit 0 atf5281e88b.check:test-typecheck: OK … 0 file(s) / 0 error(s)in debt. The first run had 2 TS2345 errors in the new test (Row | nullpassed into the metadata reader);f5281e88bfixed them.Ablations (committed state
2f5b81f3e; each leg went throughscripts/ablation-replace.mjswith its own restore)The suite resolves the subject through relative
src/imports, not through a packageexports, so nodist/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 emptygit diff HEAD. The tree was clean afterwards.kindfor every rowkindtext_sources.kindlists only the fired milestone's fieldGates (HEAD
f5281e88b)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 67 commands. All 67 were run, and each exit code was recorded before any pipe.pnpm check:dual-build-cjs-loadsandpnpm check:i18nfirst exited 3 (PREREQUISITE NOT MET: nodist/). They exited 0 oncecheck:type-check-debthad built the workspace (i18n: "OK (9 package(s) — all bundles in sync").--ranreconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN".Lint was narrowed, and the narrowing is measured.
pnpm exec eslint --no-inline-config --format jsonon the 3 changed.tsfiles gave 3 files in the JSON, 0 errors and 0 warnings. These are the diff's whole TypeScript population; the fourth path is a.changesetMarkdown file, which nofilesglob ineslint.config.mjsmatches.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change the verdict on any file it does not touch.Acceptance notes
type, which comes from the declaration, and itssummary. The summary keeps the template's literal wording, andtext_sources.summarynames only the interpolated tokens, never the watched field. Probe at2f5b81f3e, through the same served read, as a reader withheldstage:{type: 'completed', summary: 'Won item'}, withstageredacted out ofmetadata. Both columns state that the withheld field enteredwon. This PR'skindis gated, so it adds nothing to that disclosure. The type and summary halves are reported for the seat to route.Generated by Claude Code