Skip to content

fix(metadata-protocol): a migrate-stored pass records the user who ran it, or nobody — never a label - #23085

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

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23068-migrate-actor

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23068
Clause-②: no

The stored-metadata migration pass and its three doors passed a label as PutOptions.actor. The contract (#4556) allows a user id or null, never a label. With this change, each producer passes the acting user's id when there is one and nothing otherwise, so the write records null. The pass still names itself on source: 'migrate-stored', as before. This follows the triage direction in comment 6128747332: one PR covering the four producers and the describe, and no repository-side filter.

Measured first, on origin/main 275ad4229a

  • Reproduced. I built metadata-core and metadata-protocol from 275ad4229a and ran a scratch probe of migrateStoredMetadata({ apply: true }) over one legacy object row in a stub engine. The probe read back what the repository wrote:
    • no actor: recorded_by: ["migrate-stored"], updated_by: ["migrate-stored"];
    • the REST door's value: recorded_by: ["admin (POST /api/v1/meta/_migrate-stored)"], and the same string in updated_by;
    • the CLI's value: recorded_by: ["os migrate meta --stored"], and the same string in updated_by.
      source was migrate-stored in every run.
  • The new pins on the unfixed source (commit c1269849e7, before the fix):
    • protocol: expected [ 'migrate-stored', 'migrate-stored' ] to deeply equal [ null, null ];
    • REST: expected 'admin (POST /meta/_migrate-stored)' to be 'admin', and an isSystem caller still sent an actor;
    • runtime: expected 'admin (POST /metadata/_migrate-stored)' to be 'admin', and an isSystem caller still sent an actor;
    • CLI: both --apply and the preview sent an actor.
      The pin that passes a user id through the protocol was green before the fix, which is expected: the protocol already forwarded a caller's id unchanged.

What identity each producer has

producer identity in hand now passes
ObjectStackProtocolImplementation.migrateStoredMetadata the caller's request.actor request.actor when given. With none, nothing is forwarded, and saveMetaItem's #4556 default records null (it used to default to 'migrate-stored')
REST POST …/meta/_migrate-stored the authenticated execution context it already resolves for its manage_metadata gate resolveMetaWriteActor(environmentId, req), the same producer every other /meta write door uses (memoized per request). It returns the user id or undefined
runtime dispatcher POST /metadata/_migrate-stored executionContext.userId the user id when it is a non-empty string, otherwise nothing
os migrate meta --stored none: it boots its own stack against the database directly, with no session no actor, so the rows record null

No new log line was added. With the label gone, the door is still identifiable: source: 'migrate-stored' marks the pass on every history row, and recorded_by is a user id (a REST or runtime caller) or null (the CLI, or an isSystem caller). The two HTTP doors are the same operation, so naming which transport ran it would tell an operator nothing more.

Change

  • packages/metadata-protocol/src/protocol.ts: in migrateStoredMetadata, actor: request.actor ?? 'migrate-stored' becomes a spread that forwards actor only when one is given. The actor field's doc now states the rule, and the header's source bullet says that source is the only place the pass names itself.
  • packages/rest/src/rest-server.ts: the _migrate-stored handler takes its actor from resolveMetaWriteActor and no longer builds the inline label. That helper's header list of the doors it serves now includes this route.
  • packages/runtime/src/domains/meta.ts: the _migrate-stored branch passes the user id or nothing.
  • packages/cli/src/commands/migrate/meta.ts: runStored passes no actor.
  • packages/metadata-core/src/types.ts: MetadataItemSchema.authoredBy's describe changes from user id, "cli", "ai:claude", … to the PutOptions.actor rule: the acting user id, or null for a system-initiated write, never a label such as "system" or "cli". Readers: git grep for an authoredBy comparison, startsWith, includes or match, and for an 'ai:' prefix test, across packages/ finds no hits. No reader parses authoredBy for those labels. The schema accepts the same values as before.
  • .changeset/23068-migrate-actor.md: patch for all five packages, written for an operator.

Pins

  • packages/metadata-protocol/src/protocol.stored-migration.test.ts, new block #23068, using the file's existing stub engine (no new double and no ledger change):
    • a pass run by usr_operator records ['usr_operator'] in recorded_by and in the row's updated_by;
    • a pass with no user records [null, null] in both columns over two rewritten rows, with source still migrate-stored.
  • packages/rest/src/rest-meta-migrate-stored.test.ts: the attribution pin goes from toContain('admin'), which the label also satisfied, to toBe('admin'). A new pin checks that an isSystem caller sends no actor.
  • packages/runtime/src/domains/meta-migrate-stored.test.ts: the same two pins.
  • packages/cli/src/commands/migrate/meta.stored-actor.test.ts (new, unit tier): runs the real MigrateMeta with only the boot, the occupancy probe and the plugin list replaced, and checks that neither --apply nor the preview hands migrateStoredMetadata an actor.
  • packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts: its copy of "the command's exact call" drops the actor the command no longer sends. Its Studio-save probe now passes a user-id-shaped actor in place of the label studio-roundtrip-probe.

Ablation

The fix was committed first. Each leg went through scripts/ablation-replace.mjs in wrap mode, inside a script with its own trap that restores both files from HEAD. All four pin files resolve their subject from source (relative imports, or a mocked protocol), so no rebuild was involved.

  • Leg A: the protocol default restored to request.actor ?? 'migrate-stored'. The anchor hit once and fell 1 to 0, and the blob changed from 799733c6b23f to 9290dc96e3ad. Result: the protocol #23068 block had 1 failed and 1 passed (expected [ 'migrate-stored', 'migrate-stored' ] to deeply equal [ null, null ]; the user-id pin stayed green). REST 16/16, runtime 14/14 and CLI 2/2 stayed green.
  • Leg B: the REST door restored to the route-name label. The anchor hit once and fell 1 to 0, and the blob changed from 712ca3eb781b to 79e1b90a562a. Result: REST had 2 failed and 14 passed. The protocol #23068 pins (2/2), runtime 14/14 and CLI 2/2 stayed green.
  • Restore: both blobs are equal to HEAD (799733c6b23f, 712ca3eb781b), git diff HEAD is empty, and git status --porcelain is empty.

Verification

origin/main 038d4c9588 (#23070) was merged once, at df2f5e39df. The closure turbo run build --filter=@objectstack/cli... was rebuilt after the merge (59/59 tasks).

  • At df2f5e39df:
    • @objectstack/metadata-protocol pnpm test: 228 files passed and 3 skipped; 28205 tests passed. Exit 0.
    • @objectstack/metadata-core pnpm test: 18 files, 685 tests. Exit 0.
    • @objectstack/rest pnpm test (--project local): 274 files, 5752 tests passed. Exit 0.
    • @objectstack/runtime pnpm test (--project local): 359 files, 5154 tests passed. Exit 0.
    • @objectstack/cli --project unit: 284 files, 4272 tests. Exit 0.
    • @objectstack/cli --project integration on the one integration file this PR edits: 3/3. Exit 0. The rest of the integration tier is left to CI.
    • typecheck for metadata-core, metadata-protocol, rest and cli: exit 0.
  • At 167dca9b79: runtime's typecheck first went red at df2f5e39df, because the new pin grew the shrink-only test-typecheck ledger by one TS18048. 167dca9b79 reads the response through optional chaining, after which the runtime typecheck exits 0 and meta-migrate-stored.test.ts passes 14/14. That commit changes one line of that one test file.
  • Gates, at the final head 167dca9b79: the lead list (59 commands) plus the 11 that re-derivation on the real diff added (the changeset family), 70 in all. Each exit code was captured before any pipe, and all 70 exited 0. dispatch-gates --ran, given the exit codes, reports 70 derived, 70 run, 0 NOT-MEASURED (a derived zero) and 0 UNRUN. Both check:dual-build-cjs-loads and check:i18n-coverage read real built output in this run (13 configs; 621 baselined, none new).
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the 10 changed .ts files gives 10 files, 0 errors and 0 warnings. An ignored file would have reported an ignore warning, so all 10 were linted. The repository's single eslint.config.mjs never enables type-aware linting (its own comment says: no parserOptions.project, no typed @typescript-eslint rules), so this diff cannot change the verdict on any file it does not touch. The repository-wide pnpm lint is left to CI.

Other PutOptions.actor producers that pass a label (outside this claim; reported, not fixed)

A git grep across non-test packages/**/*.ts for an actor: set to a string literal, or for actor ?? / actor || falling back to one, found:

  • packages/plugins/plugin-security/src/permission-set-projection.ts, reconcilePermissionSetProjection (called from security-plugin.ts), runs saveMetaItem({ type: 'permission', …, actor: 'system' }). That is sys_metadata_history.recorded_by 是 lookup('sys_user') 却存哨兵字符串 'system'——声明的类型与实际存的值不是一回事 #4556's retired sentinel, and it lands in recorded_by, created_by and updated_by.
  • packages/plugins/plugin-security/src/position-environment-backfill.ts, backfillRowOnlyPositions, runs saveMetaItem({ …, actor: 'system' }), with the same result.
  • These are not PutOptions.actor and were not touched: sys_metadata_audit.actor's 'system' default (a Field.text column), the publish materializer's actor: args.actor ?? 'system', and contract-suite.ts's 't'/'tester' fixtures.

Acceptance notes

  • FileSystemRepository (packages/metadata-fs) defaults fsActor to 'fs', and that value reaches both MetadataEvent.actor (whose describe already says "never a sentinel string") and MetadataItem.authoredBy. MetadataPlugin attaches the repository without passing fsActor. This was already off-contract for events, and it now disagrees with the aligned authoredBy describe too. Reported for the seat. Not changed here.
  • Rows that a migrate-stored pass rewrote before this release keep their label in recorded_by and updated_by. The recorded-by-sentinel journal plan rewrites only 'system', and its header claim that the column "has only ever held one sentinel" does not cover these labels. Whether a cleanup should follow is raised with the seat.
  • packages/metadata-core/test/types.test.ts still uses 'cli' as an authoredBy and actor fixture. The schema still accepts it. It is outside this claim's files, so it was left alone.

Generated by Claude Code

…r door

A migrate-stored pass passes its actor to PutOptions.actor, which lands in
sys_metadata_history.recorded_by and the rewritten row's updated_by. These
pins state the contract (a user id, or nothing) at the protocol, the REST
and runtime doors, and the CLI. They are red until the producers stop
passing a label.

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
…n it, or nobody

PutOptions.actor is the acting user's id or null, never a label. The
migrate-stored pass and its three doors passed a label instead, and it
landed in sys_metadata_history.recorded_by and the rewritten row's
updated_by, both lookup('sys_user') columns.

- protocol: migrateStoredMetadata forwards request.actor only when one
  is given; with none, saveMetaItem records null. The pass's name stays
  on source: 'migrate-stored'.
- rest: POST /_migrate-stored takes the actor from resolveMetaWriteActor,
  the producer every /meta write door shares (user id or nothing).
- runtime: POST /metadata/_migrate-stored passes the caller's user id or
  nothing.
- cli: os migrate meta --stored passes no actor; it has no user.
- metadata-core: MetadataItemSchema.authoredBy's describe states the
  same rule as PutOptions.actor.

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
…eck ledger

The isSystem pin read res.response.status, which tsc reports as possibly
undefined (TS18048) and grew the shrink-only debt ledger by one. Read it
through optional chaining; an undefined response still fails toBe(200).

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

This PR changes 5 package(s): @objectstack/cli, @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/rest, @objectstack/runtime, touching 7 documentable anchor(s).

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0aef7888166501b9c6109ef602c97f793884b496.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 51 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 0aef7888166501b9c6109ef602c97f793884b496 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c6b28327aa991389955a01df5369c344b04dd16b — the merge of head 167dca9b79bbd2af89f363cd39b791532b05d7a1 into base 0aef7888166501b9c6109ef602c97f793884b496, 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 c6b28327aa991389955a01df5369c344b04dd16b && git checkout c6b28327aa991389955a01df5369c344b04dd16b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0aef7888166501b9c6109ef602c97f793884b496 167dca9b79bbd2af89f363cd39b791532b05d7a1 && git checkout -B drift-repro 0aef7888166501b9c6109ef602c97f793884b496 && git merge --no-ff 167dca9b79bbd2af89f363cd39b791532b05d7a1

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0aef7888166501b9c6109ef602c97f793884b496 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 19:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 19:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 8c10776 Oct 12, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23068-migrate-actor branch October 12, 2026 19:50
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