Skip to content

fix(spec,rest): the record page's timeline count counts one create or delete written to both sources once - #30405

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-pqdmqj
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-pqdmqj

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

维护者速读

  • 改了什么:记录页「动态 N」角标对有审计读权限的人,一次「新建」或「删除」只算一条了(此前活动行和审计行各算一次)。一建一改现在显示「动态 2」,和面板画出的两行一致。
  • 为什么改:PR fix(ui): one record action reads as one timeline row with its field changes; every timeline peek row names its actor #30342 已让动态流把一次操作画成一行,但服务端计数仍把新建算两次,管理员对数时看到「动态 3」压在两行上,会以为丢了条目。
  • 风险与回滚:只改 REST 的计数和 spec 的公式说明,不改数据、不改写入。有字段变更时每条 updated 照旧让位;created/deleted 按「活动条数与审计条数取小」让位,所以开启字段历史前就建的记录不会少算。唯一可能少算一行的情形:同一次新建/删除的两条记录操作人不同或间隔超过 5 秒,客户端不配对(spec 已写明)。回滚即撤本 PR。
  • 先看:packages/rest/src/record-page-routes.ts 的 answerTimeline(ofAction 与 paired);packages/spec/src/api/record-page.zod.ts 的 RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES 与计数表;packages/rest/src/record-page-routes.test.ts 的 [#30343] 块。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/record-page-routes.test.ts -t 30343,预期 5 条全绿;其中「一建一改 → 2」在 main 上得 3。
  • 需要你做的:无,席位落地。

Fixes #30343

Clause-②: yes (narrowing) — RecordPageTimelineCountSchema.total falls for the same rows: with a field change counted, created (deleted) activity rows now step aside as often as the ledger holds create (delete) writes, beside the updated rows that already did; widening in the same line: @objectstack/spec/api exports one more constant, RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES. No exported member is removed or renamed (RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE keeps its name and role).
Metadata-protocol: none — a read answer's count, not anything an author writes.
Skills-docs: none — git grep -nE 'RECORD_TIMELINE|fieldChanges|timeline.*(total|count)|step aside' -- skills/ has 0 hits.

What changes

The ruling. Option A as the seat's PM discretion, sharpened after a measurement. PR #30342's client fold (ui/packages/plugin-detail/src/recordTimelineStream.ts, pairActions and mergeTimelineStream) is a per-row pairing, not a type rule: an audit create/update/delete entry pairs with a created/updated/deleted activity row of the same actor stamped within TIMELINE_PAIR_WINDOW_MS (5 s), nearest first, each once. On top, with any field change, an unpaired updated row still steps aside; an unpaired created/deleted row stays. A count-only server cannot pair rows. So the seat ruled (seat thread, 2026-11-05T08:04Z) the closest countable rule: with a field change counted, step aside every updated row, plus min(created rows, create writes) and min(deleted rows, delete writes). This is exact whenever plugin-audit writes both rows in one call (the common case). Option A taken literally (every created/deleted row steps aside) would under-count a record created before its history was kept: the client draws created + the edit (2), A would count 1.

REST. answerTimeline reads four more counts when both activity and fieldChanges are named: sys_activity by type created and deleted, sys_audit_log by action create and delete, each as the caller through readStream, in the same Promise.all. A refused ledger count pairs nothing (as a refused ledger total already counts no field change); a refused activity count refuses the part, as before.

Spec. The table row, before:

| `activity`     | `sys_activity` where `object_name` = `{object}`, `record_id` = `{id}` and `type` is not one |
|                | of `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`; with any field change counted, not             |
|                | `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE` either                                               |

after:

| `activity`     | `sys_activity` where `object_name` = `{object}`, `record_id` = `{id}` and `type` is not one |
|                | of `RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES`; with any field change counted, less every      |
|                | `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE` row, and less, for `created` and for `deleted`       |
|                | (`RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`), the fewer of that type's rows and the ledger's   |
|                | writes of the paired action (`create`, `delete`)                                            |

The new constant RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES = { create: 'created', update: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, delete: 'deleted' } sits beside the update one, whose docblock now says every update steps aside and a create or delete only where paired. The "upper bound" sentences (module docblock, table text, RecordPageTimelineCountSchema docblock and total's describe()) keep the showCompleted / types / no-diff cases and gain the one case that falls the other way: a create (delete) whose two rows the panel does not pair. fieldChanges's describe() names the created/deleted step-aside. content/docs/references/api/record-page.mdx, api-surface/api.json and export-origins/api.json are the generators' output.

Tests

  • packages/rest/src/record-page-routes.test.ts [#30343] block, row-backed (each count filtered from rows by its own where): one create + one edit, audit reader → 2 (red on main: 3); same rows, ledger refused (member without audit read) → 2, unchanged; created before history (no create write) + one edit → 2; create + edit + delete → 3; a comment still adds one → 3. The [#28737] read-set pin now lists the 8 reads and its sum (262) carries the paired create.
  • packages/spec/src/api/record-page.test.ts: the new map pairs the three write actions with drawn built-in types, update being RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE.
  • packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts: the expected sum from separate list reads adds the paired step-aside; on the real producers (one create, two renames) paired = 1.
  • Ablation (one run, then restored): paired += Math.min(…) replaced by paired += 0 through scripts/ablation-replace.mjs → 4 failed / 33 passed in record-page-routes.test.ts (the 262 pin, create+edit → 2, create+edit+delete → 3, comment → 3); restore verified blob == HEAD.

Acceptance notes

Verification

Head 821fd5381 (merge of origin/main 8fad7b1; regenerating spec build + gen:api-surface + gen:export-origins + gen:docs + gen:liveness-counts on it wrote no diff).

Derived gates: 104 derived, 104 run, 0 NOT-MEASURED

node scripts/pm/dispatch-gates.mjs --ran on dc2c02ab8: ✓ 104 derived famil(ies) accounted for — 104 run, 0 NOT-MEASURED. The loop ran on 1c674657; the two commits after it (the changeset's adr-0087: marker, the test double refusing combinators) were answered by re-running the gates they touch. 102 exit 0 on the pre-merge tree; the 2 red (check:liveness, check:generated) were the main red above. Re-run on the merged head 821fd5381, all exit 0: check:liveness, check:generated, check:api-surface, check:export-origins, check:docs, check-adr-0087-registration --base origin/main, check:where-matcher, check-changeset-no-major. check:skill-examples, check:dual-build-cjs-loads and check-plugin-teardown-shape --self-test exit 0 after a whole-workspace build and the pinned-commit fetch. Lists: project files server-seat5/gates-30343-*.txt.

Suites, lint, dogfood
  • pnpm --filter @objectstack/rest test: 386 files, 7335 passed, 332 skipped. record-page-routes.test.ts on the merged head: 37/37.
  • pnpm --filter @objectstack/rest typecheck, pnpm --filter @objectstack/spec typecheck: exit 0.
  • pnpm --filter @objectstack/spec test: 25553 passed, 24 failed, all 24 in scripts/liveness/check-liveness.test.ts (the main red above). On the merged head, record-page.test.ts + check-liveness.test.ts: 85/85.
  • pnpm lint: exit 0.
  • Full dogfood (narrowing arm), pnpm --filter @objectstack/dogfood test under the lock after turbo run build --filter='!@objectstack/docs', on dc2c02ab8: VERDICT command-exit 0, Test Files 419 passed / 3 skipped, Tests 3492 passed / 36 skipped, 0 failed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CYeVL11pNodMuPhy8q

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/export-origins/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/export-origins/api.json) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 173 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0e4dfbe27a2a312864cd8d14e4954b6af14e2a22 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8fcb7b626a86bcae52eb608f58613de3e0a3e48d — the merge of head 821fd53816601d9cbc4b679f3e7d3b137a7efd94 into base 0e4dfbe27a2a312864cd8d14e4954b6af14e2a22, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8fcb7b626a86bcae52eb608f58613de3e0a3e48d && git checkout 8fcb7b626a86bcae52eb608f58613de3e0a3e48d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e4dfbe27a2a312864cd8d14e4954b6af14e2a22 821fd53816601d9cbc4b679f3e7d3b137a7efd94 && git checkout -B drift-repro 0e4dfbe27a2a312864cd8d14e4954b6af14e2a22 && git merge --no-ff 821fd53816601d9cbc4b679f3e7d3b137a7efd94

node scripts/docs-audit/affected-docs.mjs --json 0e4dfbe27a2a312864cd8d14e4954b6af14e2a22

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 821fd53816601d9cbc4b679f3e7d3b137a7efd94
Local-runs: none
Scope: packages/spec/src/api/record-page.zod.ts + packages/rest/src/record-page-routes.ts (answerTimeline, the constant's first consumer), ui/packages/plugin-detail/src/recordTimelineStream.ts on main (pairActions, mergeTimelineStream, auditEntriesToFeedItems — the fold the rule mirrors), ui/packages/app-shell/src/views/RecordDetailView.tsx:3431 (total's consumer), packages/plugins/plugin-audit/src/audit-writers.ts:432-440,1294-1297 (what writes the two rows), packages/spec/src/data/feed.zod.ts (SYS_ACTIVITY_BUILTIN_TYPES). Remainder (tests, dogfood pin, generated mdx/api-surface/export-origins, changeset): the lane seat's ACCEPT at TIER_DEFAULT.

Rendered 2026-11-05T09:55Z. Inputs: card #30343 body + 3 comments (6652104225, 6652912193, 6654843728), PR #30405 body, 9-file list, net diff 8fad7b19..821fd538, check-runs on the head.

① Derived judgments

  • Accept set, request: RecordPageTimelineRequestSchema keeps its three strict booleans; only fieldChanges's describe() grows. No input accepted or refused differently — right.
  • Accept set, answer: RecordPageTimelineCountSchema.total stays int ≥ 0; its VALUE falls for the same rows when activity and fieldChanges are both named and the ledger holds a write: minus min(created rows, create writes) + min(deleted rows, delete writes) beside the updated rows that already stepped aside. A narrowing of the answered value — right, and declared.
  • Public surface: @objectstack/spec/api gains RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES (create→created, update→RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, delete→deleted, as const satisfies Record of SysActivityBuiltinType); api-surface/api.json and export-origins/api.json carry it; nothing removed or renamed — right. All three values are in SYS_ACTIVITY_BUILTIN_TYPES and none in RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES (pinned in record-page.test.ts), so the subtraction never reaches below the drawn-activity count it is taken from — right.
  • REST read set: 4 → 8 count reads when both sources are named, all in one Promise.all as the caller via readStream (so RLS/sharing apply as before); a refused ledger count pairs nothing (consistent with a refused ledger total counting no change); a refused activity count still refuses the part. The [#28737] read-set pin lists the 8 — right.
  • The member without audit read: ledger refused → changes = 0 → nothing steps aside → same total as main — the card's "member reading unchanged" holds (pinned).
  • Stale fixture: packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts restates the spec table's formula and moves with it — right to fix in the same PR; no ui/** file changed, and the ui pins (RecordTimelineTotal-28737, RecordDetailView.timelineCount-28737) assert the client fold or canned totals, not this formula — right.

② Semver level

Changeset .changeset/30343-timeline-one-action-one-entry.md: @objectstack/spec: patch, @objectstack/rest: patch. Clause-②: yes (narrowing) on PR body and changeset, line-initial, same wording: yes (one new export) with the (narrowing) arm (the answered total falls) — a well-formed pair per clause2-line.mjs (yes (narrowing) is the widens-and-narrows shape). Metadata-protocol: none — a read answer's count; nothing an author writes changes. AGENTS.md §3: kind none ⇒ patch; a non-protocol narrowing ships patch with its ADR-0087 disposition — present, one marker, not-required (no-migration-prescription), read correctly: nothing a caller sends changes. Level matches what the diff publishes. Check Changeset and Governed Surface Queue Guard success on the head.

③ Boundary flags

  • open_questions: empty — nothing owed.
  • Dev deviation 2 (min rule, not option A literally): the card's dispatch comment 6652912193 rules option A; the "08:04Z seat ruling" the diff implements appears on the card only as the dev report's paraphrase (6654843728) and in the PR body — no ruling comment of its own. Judged on the card's Expected ("one action adds one to the badge for an audit reader; the member reading is unchanged") and on the client fold the card names: option A literally fails the Expected for a record created before history was kept (client draws created + edit = 2, A counts 1); the min rule meets it (pinned: 2). The deviation is the right call. Should-fix (seat, not the dev): post the 08:04Z ruling on the card thread in its own words, so the rule the spec now spells is traceable to a ruling, not a report.
  • Dev deviation 1 (dogfood pin outside the order's surface): accepted, see ①.
  • Dev deviation 3 (check:liveness red on main fdad178, fixed by fix(spec): repoint the query.searchFields liveness row at engine-find.ts#expandSearchOnAst #30368): not this PR's; Spec property liveness success on the merged head.
  • Skills-docs: none: verified, git grep of RECORD_TIMELINE|fieldChanges|step aside under skills/ at the head has 0 hits.
  • Check-runs at 2026-11-05T09:55Z: 62 on the head, 31 completed (23 success, 8 skipped, 0 failed) — among them Check Changeset, Governed Surface Queue Guard, PR-body guards, Dogfood Regression Gate, Spec property liveness, Lint · PM dispatch-gates, Check Documentation Links; 31 still in_progress (Build Core, Type Check ·, Test Core 1-8, Test UI 1-16, Lint · ESLint, Build & E2E UI). No gate has concluded red; the queue reads the rest as they land — this record does not stand in for them.

④ Design judgment

packages/spec/src/api/record-page.zod.ts against the ruling and its first consumers, tried against the live client:

  • Against mergeTimelineStream: the client pairs each audit entry (type-matched by AUDIT_ACTION_TO_ACTIVITY_TYPE, same actor, ≤ TIMELINE_PAIR_WINDOW_MS 5 s, nearest first, each once) onto its activity row; unpaired updated rows step aside when any change is present; unpaired created/deleted stay. The number of pairs of one type is at most min(rows, writes); the server subtracts exactly that, so total ≤ drawn in the pairing dimension and the spec's new sentence — "draws one more for a create (delete) whose two rows it does not pair: actors differ, or past its pairing window" — names precisely the gap. Holds.
  • Tried: a diff-less create entry. auditEntriesToFeedItems drops an entry with no changes before pairing. Client draws the created row alone (1); server: changes 1 + activity 1 − paired 1 = 1. Holds without a new caveat.
  • Tried: record created before history kept (no create write): client 2, server 2 (pinned). Create+edit+delete: 3 (pinned; moot for a live page of a deleted record, harmless). Comment adds one (pinned).
  • Tried: audit-writers.ts writes the ledger row then its sys_activity mirror in one call, same user — the common case where the min rule is exact, as the PR claims.
  • Tried: negative total. updates + paired is drawn from the same activity set as activity, each type disjoint, same caller, same snapshot — not reachable except under a concurrent write between count reads, which main's updates subtraction already had; nothing new.
  • Prose: table row, RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE docblock, new constant's docblock, module and count docblocks, both describe() texts say the same rule; content/docs/references/api/record-page.mdx is the generator's copy of them.

Should-fix (follow-up card, domain:ui): recordTimelineStream.ts keeps its own AUDIT_ACTION_TO_ACTIVITY_TYPE with the same three pairs; the spec constant now exists so the client can import it, as it does RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, making the pairing map one definition like RECORD_TIMELINE_UNDRAWN_ACTIVITY_TYPES. Out of this PR's surface by order (ui/** source barred); not a FAIL.

No must-fix.

Implemented-by: claude/project-thread-pqdmqj
Reviewed-by: contract-reviewer subagent of session_01Utp6CYVV6fcAQrNTDQRvPb

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

席位核对 · ACCEPT · 2026-11-05T09:56Z · head 821fd538

Reviewed against order server-seat5/dispatch-30343.md (Claim 6652912193) and its 08:05Z amendment, the min rule (ruling recorded on the card, 6655007839).

  • Contract review PASS at this head (CONTRACT_REVIEW_TIER, 6655016200): no must-fix. Its two should-fixes: the ruling's own words on the card (posted, 6655007839); the client's local AUDIT_ACTION_TO_ACTIVITY_TYPE map should import the new spec constant (a domain:ui follow-up card, ui/** source was barred here).
  • REST. answerTimeline subtracts every updated plus min(created activity, create writes) and min(deleted, delete) when a field change is counted; a refused ledger count pairs nothing, a refused activity count refuses the part, as before. Pins: create+edit → 2 (3 on main), pre-history record → 2, member without audit read unchanged, delete control, comment +1; ablation of the min turns 4 red.
  • Spec. RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES added, RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE kept with its role; the table, describe() and the upper-bound sentence name the one exception. API-surface and export-origin ledgers regenerated (+1 each).
  • Deviation accepted: the dogfood timeline pin (packages/qa/dogfood/test/record-page-timeline.dogfood.test.ts) moves with the formula; it is this total's end-to-end pin.
  • Declarations. Clause-②: yes (narrowing) plus the widening constant; Metadata-protocol: none; patch on spec and rest with the ADR-0087 not-required marker. Skills-docs: none (0 hits).
  • Gates. check-governed-merges: NOT governed, 257 lines; the remaining CI runs gate the queue.

Landing: ready + auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 09:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 09:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 29702d5 Nov 5, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-pqdmqj branch November 5, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Record page 动态 badge still counts one create twice for an audit reader after the timeline draws one row per action

1 participant