Skip to content

fix(metadata-fs, runtime): an actor-less filesystem write records null, never the label fs; the dispatcher metadata PUT records the user - #23109

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23086-actor-family
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23086-actor-family

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #23086
Clause-②: no

This PR covers positions 2 and 3 of the PutOptions.actor producer family. The contract (packages/metadata-core/src/types.ts, #4556) is the acting user's id, or null, and never a label. It follows the triage direction in comment 6131584326: positions 2 and 3 in one PR, one pin per position, no second resolver, and no new repo-wide check. Position 1 (plugin-security's 'system') is not edited here. The card stays open so the seat can verify position 1 when #22975's 7a and 7b land.

Measured first, on origin/main 6662e3f590

  • Position 1, confirmed and not edited. Both modules still pass the label. permission-set-projection.ts:1658 has actor: 'system', and so does position-environment-backfill.ts:299. The same two hits are there at 9956048b4c and at origin/main 8464380248.

  • Position 2: where 'fs' lands. FileSystemRepository's constructor set this.fsActor = opts.fsActor ?? 'fs', and fsActor reached four places:

    • the event actor of an edit seen on disk (handleFsChange, publishExternalDelete);
    • authoredBy in get(), in the list() header, and in a no-op put(), each spelled meta?.actor ?? this.fsActor.

    ?? treats a recorded null as absent. So a put() with actor: null read back from get() / list() as authored by 'fs', even though the write and its log record held null. The new pin read exactly that on the unfixed source: expected { get: 'fs', list: [ 'fs' ], … } to deeply equal { get: null, list: [ null ], … }.

  • MetadataPlugin never passes fsActor. packages/metadata/src/plugin.ts constructs the repository with root, org and disableWatch only, so every host that boots the plugin inherited the label.

  • Readers of authoredBy and the event actor from this repository: none key on 'fs'.

    • MetadataManager.applyRepoEvent reads op, ref, ts and seq, and never actor.
    • MetadataManager.getRepository() has no non-test caller.
    • No HTTP route exposes this repository's history or items, and the HMR legacy events carry no actor, so no Studio provenance display can read it.
    • LayeredRepository is never constructed over it outside tests.
    • In non-test packages/**/*.ts, a git grep finds no comparison of an actor or authoredBy with 'fs'.
    • The only readers keyed on the label were nine assertions in metadata-fs's own watcher suites (expect(… .actor).toBe('fs')). They are updated below.
    • fsActor itself has no producer anywhere in this tree.
  • Position 3. HttpDispatcher's PUT /metadata/:type/:name branch called saveMetaItem with no actor, although the execution context it had just admitted carries userId. A signed-in author's save through the @objectstack/hono catch-all therefore recorded null in sys_metadata.updated_by / created_by and sys_metadata_history.recorded_by. The new pin read exactly that on the unfixed branch: expected { created_by: null, … } to deeply equal { created_by: 'u_author', … }. RestServer's twin records the user through resolveMetaWriteActor.

Change

  • packages/metadata-fs/src/repository.ts:
    • fsActor defaults to null, and its exported type (fsActor?: string) is unchanged. Its doc now states the rule: a user id for a change the repository did not write itself, null when omitted, never a label. source: 'fs' already names the origin on every event.
    • The three authoredBy reads go through one private authoredByOf(meta). When a log record exists, it answers with that record's actor, and a recorded null stays null. Only when no record exists (a file placed on disk outside the repository) does it answer with fsActor.
  • packages/metadata/src/plugin.ts: a comment at the attach states the actor rule, as the explicit-composition rule asks. The code is unchanged. With the default gone, the attach gives null.
  • packages/runtime/src/domains/meta.ts: the PUT branch passes executionContext.userId when it is a non-empty string, and nothing otherwise. That is the same inline reading the _migrate-stored branch got in 8c10776152, with no new resolver function. The actor is read off the admitted identity only, never off item or a header.
  • .changeset/23086-actor-family.md: patch for @objectstack/metadata-fs and @objectstack/runtime, written for an operator. @objectstack/metadata is not listed because its only change is a // comment, and no comment ships. Built packages/metadata/dist holds zero hits for the new comment and zero for the pre-existing comment above it, in js, cjs and maps. The positive control, the runtime string FileSystemRepository attached, hits dist/index.js once.

Pins (one per position, plus the control)

  • Position 2, the repository: packages/metadata-fs/test/actor-never-a-label.test.ts (new). It checks the put() receipt, get(), the list() header and the history() event:

    • a write with no actor records null on every surface, a no-op rewrite included;
    • a write by usr_alice records usr_alice on every surface;
    • a file no recorded write produced reports null;
    • a named fsActor answers only where no write is recorded, and a recorded null stays null.

    The nine watcher-suite assertions now read actor as null for an edit seen on disk (source still 'fs').

  • Position 2, the MetadataPlugin door: packages/metadata/src/plugin-fs-repository-actor.test.ts (new). A real LiteKernel boots a real MetadataPlugin, and the test reads the repository handle it attached. A file placed under .objectstack/metadata before boot reports null, and so does an actor-less put(). The positive control is that the placed file was indexed. If a future attach passes a label as fsActor, this test turns red.

  • Position 3, the dispatcher door: packages/runtime/src/domains/meta-save-actor.test.ts (new). Each door gets its own real store: better-sqlite3 :memory:, the real sys_metadata* objects, and a real ObjectStackProtocolImplementation. The test reads the rows back after every save:

    • a signed-in u_author save records u_author in created_by, updated_by and recorded_by;
    • a later save by u_editor moves updated_by to u_editor, keeps created_by, and appends u_editor to recorded_by;
    • an isSystem save with no user records null in all three.

    An anonymous caller never reaches the save, because the anonymous deny answers first, so "nobody" is the isSystem caller.

  • CONTROL: the same two cases run against RestServer's PUT twin (resolveMetaWriteActor), which is unchanged and records the same rows.

  • packages/runtime/src/http-dispatcher.test.ts: the deliberate exact-match pin of the dispatcher's saveMetaItem request now includes actor: 'u1'. It was the one red in the full runtime run before that update.

Ablation

The fix and its pins were committed first. Each leg went through scripts/ablation-replace.mjs in WRAP mode, which carries its own restore trap. The legs ran inside one script that also carried an EXIT INT TERM trap restoring both files from HEAD by absolute path. The run was at 1f9c123dcf.

  • Leg A1, the 'fs' default restored (opts.fsActor ?? null back to opts.fsActor ?? 'fs'):
    • The anchor fell from 1 to 0 and the blob went from f76f6dee4f48 to bfde62909356.
    • metadata-fs was rebuilt, and ablation-dist-preflight.mjs found the marker in 2 built files. The pristine witness had found the fixed spelling in dist before the mutation.
    • Result: metadata-fs 11 failed and 63 passed. The failures were the new pin's file-with-no-record case and the ten watcher tests behind the nine updated assertions (expected 'fs' to be null). The new pin's other three cases stayed green.
    • The plugin door pin failed 1 of 1 (expected 'fs' to be null). The runtime pin passed 4 of 4.
    • Restore leg: rebuilt, and the preflight found the marker absent from all 6 built files with the tree clean.
  • Leg A2, the meta?.actor ?? this.fsActor fallback restored, with the default left null:
    • The anchor fell from 1 to 0 and the blob went from f76f6dee4f48 to c50311af3a10.
    • Result: metadata-fs 1 failed and 73 passed. The failure was the named-fsActor case, expected { get: 'usr_operator', … } to deeply equal { get: null, … }. The runtime pin passed 4 of 4.
    • The plugin pin was not run in this leg: its subject resolves through metadata-fs's dist/, which this leg did not rebuild, so its reading would have proved nothing.
  • Leg B, the dispatcher's userId dropped (const actor: string | undefined = undefined;):
    • The anchor fell from 1 to 0 and the blob went from 84a24c33019f to 5632faa44578.
    • Result: the runtime pin had 1 failed and 3 passed. The failure was the dispatcher's signed-in case, expected { created_by: null, … } to deeply equal { created_by: 'u_author', … }. The dispatcher's isSystem case and both RestServer control cases stayed green.
    • The metadata-fs pin (4/4) and the plugin pin (1/1) stayed green.
  • Restore: both blobs equal HEAD (f76f6dee4f48, 84a24c33019f). git diff HEAD is empty and git status --porcelain is empty.

Verification

origin/main 55828c5145 was merged once, at 9956048b4c. The runtime and metadata-fs changes are disjoint from it. After the merge I ran pnpm install --frozen-lockfile and turbo run build --filter=!@objectstack/docs (73/73 tasks).

  • At 9956048b4c:
    • @objectstack/metadata-fs pnpm test: 11 files, 74 tests, exit 0.
    • @objectstack/metadata pnpm test: 61 files, 880 tests, exit 0.
    • @objectstack/runtime --project local over src/http-dispatcher.test.ts and src/domains/: 110 files, 2299 tests, exit 0.
    • typecheck for metadata-fs, metadata and runtime: exit 0. Runtime's test-typecheck ledger is unchanged (27 files, 190 errors, 68 pinned signatures).
  • Before the merge, at f489836584, @objectstack/runtime's full --project local suite ran 362 files: 361 passed, and 1 failed. The failure was the exact-match pin in http-dispatcher.test.ts, updated in 1f9c123dcf. 5166 tests passed and 19 were skipped. The full runtime suite was not re-run after the merge. The incoming commits touch metadata-protocol's envelope and install-lock paths, not the save actor path, so only the meta domain, the dispatcher file and the pins were re-run. The merge ref's full run is CI's.
  • Gates, at the final head 9956048b4c: dispatch-gates --commands derived 66 commands on the real diff. That is the 55-command lead list plus 11 changeset-family commands. Each exit code was captured before any pipe, and all 66 exited 0. dispatch-gates --ran reports 66 derived, 66 run, 0 NOT-MEASURED and 0 UNRUN.
    • check:dual-build-cjs-loads read real built output after a full build (107 entries, 66 packages).
    • check:query-options-erasure is back at its 236-site test ceiling.
    • The derivation warns that origin/main has since moved by 2 commits, which changed three engine-double roster baselines. This diff adds no engine double.
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the 14 changed .ts files gives 14 files, 0 errors, 0 warnings and 0 ignore notices, so all 14 were linted. eslint.config.mjs never enables type-aware linting (its own comment at line 327: 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.

Acceptance notes

  • Entries written before this release keep what they recorded: 'fs' in an existing .objectstack/.log/main.jsonl, and null in dispatcher-saved rows. This is per the card's "Not here".
  • fsActor has no producer in this tree. Retiring it would narrow an exported options type (Clause-② narrowing). It is left in place with its type unchanged, and only its default moved. No card is filed: per the family rule, this is an observation and not a defect.
  • RestServer.resolveMetaWriteActor's docblock (packages/rest/src/rest-server.ts) still says "the protocol's 'system' / NULL defaults still apply". The 'system' half has been stale since sys_metadata_history.recorded_by 是 lookup('sys_user') 却存哨兵字符串 'system'——声明的类型与实际存的值不是一回事 #4556. The file is outside this claim, and nobody is carrying it.
  • The dispatcher PUT's fallback, metadata.saveItem(type, name, item), carries no actor either. No metadata service in this tree implements saveItem (the only hits are the client SDK's meta.saveItem), so the branch is unreachable with the in-tree MetadataManager. This is a dead-code observation, not filed.
  • A dispatcher save by a signed-in user now also gets that user in sys_metadata_audit.actor (through recordMetadataAudit) instead of that column's 'system' default, the same as on the REST door.
  • Comments in metadata-fs's self-write-suppression.test.ts that quote the pre-fix symptom {op:'update', actor:'fs'} are left as the historical record of what was measured.

Generated by Claude Code

…'; the dispatcher's metadata PUT records the user

FileSystemRepository no longer defaults fsActor to the label 'fs', and an
item's authoredBy reports the actor its log record holds, a recorded null
included. HttpDispatcher's PUT /metadata/:type/:name passes the execution
context's user id to saveMetaItem, or nothing.

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
… attach, runtime dispatcher PUT)

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 3 package(s): @objectstack/metadata-fs, @objectstack/metadata, @objectstack/runtime, touching 6 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via FileSystemRepository (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via handleMetadataRequest (symbol, a top-level function))
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)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 36 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 2808438e9dad471383140b286f8d970afa59559d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2808438e9dad471383140b286f8d970afa59559d

⚠️ 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 2808438e9dad471383140b286f8d970afa59559d → 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 13, 2026 00:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 13, 2026 00:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit a3d47c2 Oct 13, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23086-actor-family branch October 13, 2026 00:29
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

Development

Successfully merging this pull request may close these issues.

2 participants