Skip to content

fix(plugin-audit): an object definition changed through the metadata door takes effect without a restart - #24676

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24565-audit-def-cache-follows-metadata
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24565-audit-def-cache-follows-metadata

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24565
Clause-②: no

What changed

installAuditWriters (packages/plugins/plugin-audit/src/audit-writers.ts) kept two process-lifetime copies of engine.getSchema: objectDefCache behind getObjectDef and fieldDefsCache behind getFieldDefs. Each object's FIRST answer was held until a restart. Both copies are gone. getObjectDef now reads the registered definition on every call, and getFieldDefs reads .fields off that same read.

That covers every reader at once: the enable.files / enable.feeds capability gates, the ledger's credential mask and internal omission (ledgerView), the activity mirror's enable.activities switch, activityMilestones, the title field, the object label and the computed-field drop. What the metadata door accepts is unchanged.

Measured before choosing the route (on f89d29e38, real kernel)

Probe: ObjectKernel + ObjectQLPlugin (its in-house metadata protocol registered as the protocol service) + AuditPlugin + SqliteWasmDriver. It saved an object twice through protocol.saveMetaItem (the producer behind PUT /api/v1/meta/object/:name) and listened on every channel.

  • The defect reproduces. First save, no enable, then the attachment insert answers FILES_DISABLED 403. Second save with enable: { files: true }: success=true, and engine.getSchema('qa_kid').enable.files reads true. The same insert still answers FILES_DISABLED 403.
  • Which signal fires on the door's save (A2): only protocol.onMetadataMutation (object/qa_kid active), and it fires AFTER the registry holds the new definition (files=true at fire time). metadata:reloaded does NOT fire on this save. It fires on per-item publish, package publish-drafts and dev reload. The metadata service on this composition has no subscribe at all.
  • Cost of reading per call (A3): ObjectQL.getSchema is registry.getObject, which is two map reads over the registry's own memoized merge (SchemaRegistry.resolveObject / mergedObjectCache). It measured 32 to 44 ns per call against 7 ns for a bare Map.get (1,000,000 calls, shared box). The audited writes ask more often now: one audited insert went from 16 getSchema calls to 23, and one audited update from 9 to 25. That is under 1 microsecond per write, against a mean audited insert of 3,301 us before and 3,077 us after on the same harness (300 inserts each, shared-box noise).

Route: drop the copy, not invalidate it (four axes)

The card's triage text says "invalidates on the metadata change event". The dispatch offered dropping the copy as the alternative, to be chosen on the four axes. This PR drops it:

  • Real business need. Both routes fix the measured door. But the door announces on a channel metadata:reloaded subscribers never see, and the other doors that change a definition (per-item publish, package publish, dev reload with notify: false, a peer replica's replay, a delete, a plugin's own registerObject) announce on different channels or not at all. A read per call follows all of them by construction. An invalidation follows only the channels it subscribes to.
  • Long-term soundness. The registry is the one owner of an object's current definition and already memoizes it behind its own invalidation points. Its own docblock (resolveOwnerLayer) refuses a second cache for this reason. This package already reads per call where the definition decides: declaresActivityStampColumns on every sys_activity insert, and currentMilestoneWatched on the read side.
  • Keeping AI-authored metadata honest. A declaration takes effect on the next write, whatever door changed it. There is no announce for a future door to forget.
  • Startup scope. The smallest change: two maps deleted, no subscription, no new gate. ⛔ No TTL either: an expiring copy is the same stale answer for a shorter time.

Tests

New audit-writers.definition-follows-metadata.integration.test.ts, on the same real composition as the probe. Every definition change goes through protocol.saveMetaItem, and there is no restart:

  1. Object saved with no enable: attach refused (FILES_DISABLED / 403). Saved again with enable.files: true: the read-back shows true and the same attach is admitted.
  2. Reverse: saved with files: true, attach admitted. Saved with files: false: the same attach is refused again.
  3. Control (the card's): authored with enable.files: true from the start, never looked up before: admitted.
  4. Second reader, the ledger mask: an object audited once, then a password field added through the door, then the record updated. The sys_audit_log row's new_value carries the mask (SECRET_MASK), never the value.

Ablation (on committed 56218f119, through scripts/ablation-replace.mjs in wrap mode with a restore trap): the held copy was put back into getObjectDef. The anchor hit once (1 to 0), and the blob went from e5840a3a6b5b to 8bfd1f9e7ff2. Cases 1, 2 and 4 went red; control 3 stayed green:

  • 1: expected { code: 'FILES_DISABLED', status: 403 } to deeply equal { admitted: true }
  • 2: expected { admitted: true } to deeply equal { code: 'FILES_DISABLED', status: 403 }
  • 4: red at its masking assertion (the ledger row carried the field's value, not the mask).

The restore was proven by the tool: blob after restore equals the HEAD blob, and git diff HEAD is empty.

Local verification (head 30a57ba9d)

  • pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2 (whole package, on 56218f119): 58 files, 934 tests passed. The only later commit (30a57ba9d) types the new test's option bags. On that head the new file reran 4/4 green, and pnpm --filter @objectstack/plugin-audit typecheck exited 0, with check:test-typecheck: OK and 0 errors.
  • Build: the dependency closure first (turbo run build --filter=@objectstack/plugin-audit^..., 15/15), then the package itself. All of it ran under scripts/pm/os-verify-lock.sh.
  • The gate union from node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (70 commands) was rerun in full on 30a57ba9d. All 70 exited 0. dispatch-gates --ran reads: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN.
    • In the first pass, on 56218f119, check:query-options-erasure was red: the test surface grew from 236 to 237 sites because the new test passed its option bags as any. They are now typed with satisfies, and the surface is back at 236.
    • Also in that first pass, check:dual-build-cjs-loads and check:i18n exited 3 (PREREQUISITE NOT MET: no workspace dist/). Both measured green on the final head.
  • The test:repo project and the full CI farm are left to CI.

Acceptance notes

  • Rows written before this fix: a security note on them is held off GitHub for the maintainer (skills: five principle gaps the #9296 wave exposed in checklist-test / checklist-author / dogfood-verification #9387). The seat carries it.
  • Same family, other package, not measured through a door: @objectstack/metadata-core's createFieldPresenceProbe (field-set cache) and createResolver (organization-column cache) also hold a per-object answer for the process lifetime. Their docblock premise reads "object schemas are static after registration". In this file they are asked about sys_user, sys_audit_log and sys_activity, and about the audited object's organization column. They sit outside this card's file surface and were left untouched.
  • sys_attachment in the test is a stand-in under the real name (service-storage owns the definition). The gate reads only parent_object.

Generated by Claude Code

…t a process-lifetime copy

The audit writers kept each object's first getSchema answer for the life of
the process, so an object whose enable.files was switched on through the
metadata door kept refusing attachments with 403 FILES_DISABLED until a
restart, and a password field added at runtime was copied into
sys_audit_log unmasked. The registry already memoizes the merged
definition behind its own invalidation points; read it on every call.

Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
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 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 — 10 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 eadea572082976b747e0828d56c24fdf62cd8803 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json eadea572082976b747e0828d56c24fdf62cd8803

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24565 · domain:services seat 2 · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s · 2026-10-19T23:10Z · head ce67ae20d9, the review of record for claim 6277706948.

  1. Form: draft, base main. The first line is Fixes #24565, and no other closing keyword sits beside an issue number. Clause-②: no: the door accepts what the object now declares, and no accept set or spec key moves. One patch changeset for @objectstack/plugin-audit. 3 files, +203/−26. No contract face is touched, so CI and this read decide (check-governed-merges: not governed).
  2. Premise, measured:
    • The dev reproduced it on a real kernel at f89d29e38. After protocol.saveMetaItem switched enable.files on (with getSchema already reading true), the same sys_attachment insert still answered 403 FILES_DISABLED.
    • The seat's read of the diff: installAuditWriters drops its two process-lifetime copies (objectDefCache, fieldDefsCache). getObjectDef reads the registered definition on every call, and getFieldDefs reads .fields off it. Every reader of that copy now follows a metadata change: the files and feeds gates, the ledger's mask and internal omission, enable.activities, milestones, title and label. No TTL.
    • Pins on ObjectKernel + ObjectQLPlugin (its protocol) + AuditPlugin + the SQLite driver, every change through protocol.saveMetaItem: (a) toggle on admits, (b) toggle off refuses again, (c) the authored-from-start control admits, (d) a credential field added after the first audited write is masked on the ledger row. Ablation put the held copy back: (a), (b) and (d) red, (c) green, and HEAD's blob was restored.
  3. Gates:
    • Local: the package's 58 files pass (934 tests). The 70 derived families exited 0 on 30a57ba9d (--ran: 0 NOT-MEASURED, 0 UNRUN). Fix round 1 (ce67ae20d9, prose and a renamed test constant, +5/−7) re-ran the new file (4/4), the typecheck and check:query-options-erasure: all exit 0.
    • The seat's diff read: 0 expect( lines removed.
    • CI on the head: landing waits for every check.
  4. Accepted deviations:
    • Route A3, not event invalidation. The dev measured the metadata door's save. It fires protocol.onMetadataMutation after the registry holds the new definition; metadata:reloaded does not fire there, and other doors announce elsewhere or not at all. So an invalidation would follow only the channels it subscribed to. A read is two map lookups behind the registry's own memo (measured at about 40 ns, against 7 ns for the old Map.get), and the package already reads per call elsewhere. The seat takes A3 on the four axes.
    • The pin drives protocol.saveMetaItem, one frame below PUT /api/v1/meta/object/:name, over the SQLite driver this package already depends on.
    • A security note on rows written before this fix is held off GitHub for the maintainer (skills: five principle gaps the #9296 wave exposed in checklist-test / checklist-author / dogfood-verification #9387). The seat carries it.
  5. Out of scope: @objectstack/metadata-core's createFieldPresenceProbe and createResolver hold a per-object answer for the process lifetime on the same "schemas are static" premise. That is a read-only inference with no door measured, noted in the PR's Acceptance notes (carrier: none).
  6. Landing: ordinary queue landing. When every check on the head is green, the seat sends pr_ready + automerge_enable through the relay, then confirms MERGED and clears the card.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 23:36
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 23:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 5018fc3 Oct 20, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24565-audit-def-cache-follows-metadata branch October 20, 2026 00:02
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