Repository navigation
fix(metadata-protocol): a migrate-stored pass records the user who ran it, or nobody — never a label - #23085
Conversation
…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>
📓 Docs Drift CheckThis PR changes 5 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 51 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 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
|
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 ornull, never a label. With this change, each producer passes the acting user's id when there is one and nothing otherwise, so the write recordsnull. The pass still names itself onsource: '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/main275ad4229ametadata-coreandmetadata-protocolfrom275ad4229aand ran a scratch probe ofmigrateStoredMetadata({ apply: true })over one legacyobjectrow in a stub engine. The probe read back what the repository wrote:recorded_by: ["migrate-stored"],updated_by: ["migrate-stored"];recorded_by: ["admin (POST /api/v1/meta/_migrate-stored)"], and the same string inupdated_by;recorded_by: ["os migrate meta --stored"], and the same string inupdated_by.sourcewasmigrate-storedin every run.c1269849e7, before the fix):expected [ 'migrate-stored', 'migrate-stored' ] to deeply equal [ null, null ];expected 'admin (POST /meta/_migrate-stored)' to be 'admin', and anisSystemcaller still sent anactor;expected 'admin (POST /metadata/_migrate-stored)' to be 'admin', and anisSystemcaller still sent anactor;--applyand the preview sent anactor.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
ObjectStackProtocolImplementation.migrateStoredMetadatarequest.actorrequest.actorwhen given. With none, nothing is forwarded, andsaveMetaItem's #4556 default recordsnull(it used to default to'migrate-stored')POST …/meta/_migrate-storedmanage_metadatagateresolveMetaWriteActor(environmentId, req), the same producer every other/metawrite door uses (memoized per request). It returns the user id orundefinedPOST /metadata/_migrate-storedexecutionContext.userIdos migrate meta --storednullNo new log line was added. With the label gone, the door is still identifiable:
source: 'migrate-stored'marks the pass on every history row, andrecorded_byis a user id (a REST or runtime caller) ornull(the CLI, or anisSystemcaller). 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: inmigrateStoredMetadata,actor: request.actor ?? 'migrate-stored'becomes a spread that forwardsactoronly when one is given. Theactorfield's doc now states the rule, and the header'ssourcebullet says thatsourceis the only place the pass names itself.packages/rest/src/rest-server.ts: the_migrate-storedhandler takes its actor fromresolveMetaWriteActorand 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-storedbranch passes the user id or nothing.packages/cli/src/commands/migrate/meta.ts:runStoredpasses noactor.packages/metadata-core/src/types.ts:MetadataItemSchema.authoredBy's describe changes fromuser id, "cli", "ai:claude", …to thePutOptions.actorrule: the acting user id, ornullfor a system-initiated write, never a label such as"system"or"cli". Readers:git grepfor anauthoredBycomparison,startsWith,includesormatch, and for an'ai:'prefix test, acrosspackages/finds no hits. No reader parsesauthoredByfor those labels. The schema accepts the same values as before..changeset/23068-migrate-actor.md:patchfor 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):usr_operatorrecords['usr_operator']inrecorded_byand in the row'supdated_by;[null, null]in both columns over two rewritten rows, withsourcestillmigrate-stored.packages/rest/src/rest-meta-migrate-stored.test.ts: the attribution pin goes fromtoContain('admin'), which the label also satisfied, totoBe('admin'). A new pin checks that anisSystemcaller sends noactor.packages/runtime/src/domains/meta-migrate-stored.test.ts: the same two pins.packages/cli/src/commands/migrate/meta.stored-actor.test.ts(new,unittier): runs the realMigrateMetawith only the boot, the occupancy probe and the plugin list replaced, and checks that neither--applynor the preview handsmigrateStoredMetadataanactor.packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts: its copy of "the command's exact call" drops theactorthe command no longer sends. Its Studio-save probe now passes a user-id-shaped actor in place of the labelstudio-roundtrip-probe.Ablation
The fix was committed first. Each leg went through
scripts/ablation-replace.mjsin wrap mode, inside a script with its owntrapthat restores both files fromHEAD. All four pin files resolve their subject from source (relative imports, or a mocked protocol), so no rebuild was involved.request.actor ?? 'migrate-stored'. The anchor hit once and fell 1 to 0, and the blob changed from799733c6b23fto9290dc96e3ad. Result: the protocol#23068block 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.712ca3eb781bto79e1b90a562a. Result: REST had 2 failed and 14 passed. The protocol#23068pins (2/2), runtime 14/14 and CLI 2/2 stayed green.HEAD(799733c6b23f,712ca3eb781b),git diff HEADis empty, andgit status --porcelainis empty.Verification
origin/main038d4c9588(#23070) was merged once, atdf2f5e39df. The closureturbo run build --filter=@objectstack/cli...was rebuilt after the merge (59/59 tasks).df2f5e39df:@objectstack/metadata-protocolpnpm test: 228 files passed and 3 skipped; 28205 tests passed. Exit 0.@objectstack/metadata-corepnpm test: 18 files, 685 tests. Exit 0.@objectstack/restpnpm test(--project local): 274 files, 5752 tests passed. Exit 0.@objectstack/runtimepnpm test(--project local): 359 files, 5154 tests passed. Exit 0.@objectstack/cli--project unit: 284 files, 4272 tests. Exit 0.@objectstack/cli--project integrationon the one integration file this PR edits: 3/3. Exit 0. The rest of the integration tier is left to CI.typecheckformetadata-core,metadata-protocol,restandcli: exit 0.167dca9b79:runtime'stypecheckfirst went red atdf2f5e39df, because the new pin grew the shrink-only test-typecheck ledger by one TS18048.167dca9b79reads the response through optional chaining, after which theruntimetypecheckexits 0 andmeta-migrate-stored.test.tspasses 14/14. That commit changes one line of that one test file.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. Bothcheck:dual-build-cjs-loadsandcheck:i18n-coverageread real built output in this run (13 configs; 621 baselined, none new).eslint --no-inline-config --format jsonover the 10 changed.tsfiles 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 singleeslint.config.mjsnever enables type-aware linting (its own comment says: noparserOptions.project, no typed@typescript-eslintrules), so this diff cannot change the verdict on any file it does not touch. The repository-widepnpm lintis left to CI.Other
PutOptions.actorproducers that pass a label (outside this claim; reported, not fixed)A
git grepacross non-testpackages/**/*.tsfor anactor:set to a string literal, or foractor ??/actor ||falling back to one, found:packages/plugins/plugin-security/src/permission-set-projection.ts,reconcilePermissionSetProjection(called fromsecurity-plugin.ts), runssaveMetaItem({ type: 'permission', …, actor: 'system' }). That issys_metadata_history.recorded_by是lookup('sys_user')却存哨兵字符串'system'——声明的类型与实际存的值不是一回事 #4556's retired sentinel, and it lands inrecorded_by,created_byandupdated_by.packages/plugins/plugin-security/src/position-environment-backfill.ts,backfillRowOnlyPositions, runssaveMetaItem({ …, actor: 'system' }), with the same result.PutOptions.actorand were not touched:sys_metadata_audit.actor's'system'default (aField.textcolumn), the publish materializer'sactor: args.actor ?? 'system', andcontract-suite.ts's't'/'tester'fixtures.Acceptance notes
FileSystemRepository(packages/metadata-fs) defaultsfsActorto'fs', and that value reaches bothMetadataEvent.actor(whose describe already says "never a sentinel string") andMetadataItem.authoredBy.MetadataPluginattaches the repository without passingfsActor. This was already off-contract for events, and it now disagrees with the alignedauthoredBydescribe too. Reported for the seat. Not changed here.recorded_byandupdated_by. Therecorded-by-sentineljournal 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.tsstill uses'cli'as anauthoredByandactorfixture. The schema still accepts it. It is outside this claim's files, so it was left alone.Generated by Claude Code