Skip to content

fix(metadata-protocol): a metadata row records who created it and who last changed it - #23067

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23033-metadata-row-actor
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23033-metadata-row-actor

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23033
Clause-②: no

SysMetadataRepository.put now writes the actor onto the sys_metadata row. It stamps created_by when the row is first written and updated_by on every write, both from PutOptions.actor, as the repository's own header mapping (actor → updated_by) declares. A write with no actor stores NULL in both columns, never a placeholder user. History's recorded_by is unchanged. This follows the triage direction (comment 6124803255).

Measured first, on origin/main df7a137923

  • Reproduced. Saving through the metadata door (saveMetaItem with actor: 'usr_alice') on a real kernel (ObjectQLPlugin: the real engine, the real audit hooks, the real sys_metadata declaration) left created_by and updated_by absent on the stored row (expected undefined to be 'usr_alice', 3 of the 4 new real-engine pins red). The history row's recorded_by carried the actor (the existing sys_metadata_history.recorded_by 是 lookup('sys_user') 却存哨兵字符串 'system'——声明的类型与实际存的值不是一回事 #4556 pins stayed green).
  • Why. put() built parentRowData with neither column and wrote it with context: { ...ctx, isSystem: true }, a context that carries no user. The engine's audit hooks (sys_stamp_audit_insert and sys_stamp_audit_update in objectql/src/plugin.ts) stamp the *_by pair only inside if (session?.userId), so they wrote nothing either.
  • Does the engine strip or overwrite a client-supplied pair on an isSystem write? No. With no session.userId the hooks assign nothing, and isSystem exempts the write from the static-readonly strip on insert and on update (created_by is readonly: true on sys_metadata). So the pins read the row the driver stored, not the payload. The real-engine file exists to pin exactly this.
  • What callers pass as actor. The REST /meta doors pass the authenticated user id or undefined (resolveMetaWriteActor, A bearer-authenticated admin metadata write is stamped actor: 'system' — req.user / req.userId are unset on the /meta PUT path #7749 and [Decision] Should X-Actor keep outranking the authenticated identity on /meta writes? (surfaced by #7749) #7941; 'system' is only the audit-row default, recordMetadataAudit). saveMetaItem forwards request.actor ?? null, promoteDraft and restoreVersion forward their own actor, and boot or internal writes pass none. The columns are lookup('sys_user'). Under isSystem the engine runs no reference check, so a non-user string would be stored as given. The repository stamps exactly what recorded_by already records: one value, two columns. See the acceptance notes for the one producer that passes a label.

Other writers of the parent row in this class

  • put: the insert and the update, both stamped (the only two engine.insert / engine.update calls on sys_metadata in the class).
  • promoteDraft and restoreVersion: both write the active row through put(..., { actor: opts.actor }), so they get the same stamp. A publish or a restore that creates the active row records its own actor as created_by; one that updates the row moves updated_by.
  • delete and dropPromotedDraftRow: both hard-delete the row, so no row is left to stamp. The tombstone's recorded_by keeps the actor, as before.
  • The read side needed no change. rowToItem's authoredBy and listDrafts' updatedBy read updated_by ?? created_by ?? null, and with the write side stamping, both name the last writer (pinned below).

Change

  • packages/metadata-protocol/src/sys-metadata-repository.ts: updated_by: opts.actor ?? null in the row put() builds, and created_by = opts.actor ?? null on the insert branch only, beside the existing updated_at / created_at. The header's mapping table gains the created_by row.
  • .changeset/23033-metadata-row-actor.md: patch for @objectstack/metadata-protocol.

Pins

  • packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts: a new #23033 block beside the sys_metadata_history.recorded_by 是 lookup('sys_user') 却存哨兵字符串 'system'——声明的类型与实际存的值不是一回事 #4556 pins, on the file's existing engine double (no new double, no ledger change). It covers first write A, then A/A; second write B, then A/B with history [A, B]; no actor, then NULL/NULL; an authored write followed by an actor-less one, then A/NULL; and the read side (get().authoredBy and listDrafts().updatedBy name the last writer).
  • packages/objectql/src/protocol-metadata-row-actor.test.ts (new): the same rule through the door, read off the row the real engine stored. It covers first write A, then A/A with history [A]; second write B, then A/B with history [A, B]; the CONTROL (no actor, then both null, history [null]); and listDrafts answering updatedBy from the draft row. It sits in objectql because metadata-protocol must not depend on objectql, the same reason plugin.authoring-channel.test.ts sits there.
  • Ablation (fix committed first; script trap-guarded; restore by git checkout HEAD --, proved by blob == HEAD and an empty git diff HEAD). Both stamp lines were deleted through scripts/ablation-replace.mjs (anchor 1 to 0 each), metadata-protocol was rebuilt, and ablation-dist-preflight --absent confirmed both markers were gone from dist/.

Verification (head d4ac870f46, after merging origin/main 0cbfb7c32c)

  • @objectstack/metadata-protocol vitest run: 227 files passed, 3 skipped; 28197 tests passed. typecheck (tsc --noEmit, include: src/**/*, tests included): exit 0.
  • @objectstack/objectql vitest run --project local: 407 files passed, 7940 tests passed. typecheck (tsc --noEmit && check:test-typecheck): exit 0, and the new test file compiles under tsconfig.test.json with no debt entry.
  • Gates: the dispatch list (53 commands) plus the 13 added by re-derivation on the real diff (changeset families), 66 in all, each exit captured before any pipe. All exit 0 except pnpm check:dual-build-cjs-loads, which is NOT MEASURED: it printed PREREQUISITE NOT MET because the whole workspace's dist/ is not built in this worktree, and CI runs it.
  • integration-tier and other whole-tree runs are left to CI.

Acceptance notes

  • A producer passes a label as actor (out of scope here; reported for the seat to file). migrateStoredMetadata defaults actor to 'migrate-stored', and its three callers pass labels, not user ids: POST /meta/_migrate-stored passes 'USER (POST PREFIX/_migrate-stored)' (rest-server.ts), the runtime door passes the same shape (runtime/src/domains/meta.ts), and the CLI passes 'os migrate meta --stored'. PutOptions.actor declares the value lands in a lookup('sys_user') and must be null, never a label, when there is no user id. Those labels already reach recorded_by. With this change they also reach updated_by on the rows the pass rewrites. The repository cannot tell a label from a user id, and contract-first puts the fix at the producer, which also lies outside this card's file surface. The fix is to pass the user id or null and carry the door's name in source.
  • A second writer of sys_metadata lives outside this class. persistDatasourceRow (service-datasource/src/datasource-admin-plugin.ts) inserts and updates the row directly, with no actor in hand and no history row. A /meta-written datasource row that is later edited through the datasource admin door keeps the /meta writer's updated_by (read from the code, not run). Not touched; there is no carrier.
  • The install request's actor field (InstallPackageRequestSchema) is a new contract key and is deliberately not here, per triage.
  • Rows written before this release keep null columns until their next write. Nothing backfills them, since history alone knows the writer.

Generated by Claude Code

…d before the fix)

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
…nd who last changed it

SysMetadataRepository.put stamps created_by on insert and updated_by on
every write from PutOptions.actor, as its header's actor -> updated_by
mapping declares. A missing actor stores NULL in both, never a
placeholder user. History's recorded_by is unchanged.

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
…ent-wide repository listDrafts reads

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 40 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 11 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 84c4fffbcd6cf50a171d5c7c2c79e81f0e7bdbcf → packageMentionDocs.

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

Projects

None yet

2 participants