Repository navigation
fix(plugin-audit): an object definition changed through the metadata door takes effect without a restart - #24676
Conversation
…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>
…tadata pin Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 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
Coarse fallback — 10 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 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 |
Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT · #24565 ·
Generated by Claude Code |
Fixes #24565
Clause-②: no
What changed
installAuditWriters(packages/plugins/plugin-audit/src/audit-writers.ts) kept two process-lifetime copies ofengine.getSchema:objectDefCachebehindgetObjectDefandfieldDefsCachebehindgetFieldDefs. Each object's FIRST answer was held until a restart. Both copies are gone.getObjectDefnow reads the registered definition on every call, andgetFieldDefsreads.fieldsoff that same read.That covers every reader at once: the
enable.files/enable.feedscapability gates, the ledger's credential mask andinternalomission (ledgerView), the activity mirror'senable.activitiesswitch,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 theprotocolservice) +AuditPlugin+SqliteWasmDriver. It saved an object twice throughprotocol.saveMetaItem(the producer behindPUT /api/v1/meta/object/:name) and listened on every channel.enable, then the attachment insert answersFILES_DISABLED 403. Second save withenable: { files: true }:success=true, andengine.getSchema('qa_kid').enable.filesreadstrue. The same insert still answersFILES_DISABLED 403.protocol.onMetadataMutation(object/qa_kid active), and it fires AFTER the registry holds the new definition (files=trueat fire time).metadata:reloadeddoes NOT fire on this save. It fires on per-item publish, package publish-drafts and dev reload. The metadata service on this composition has nosubscribeat all.ObjectQL.getSchemaisregistry.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 bareMap.get(1,000,000 calls, shared box). The audited writes ask more often now: one audited insert went from 16getSchemacalls 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:
metadata:reloadedsubscribers never see, and the other doors that change a definition (per-item publish, package publish, dev reload withnotify: false, a peer replica's replay, a delete, a plugin's ownregisterObject) 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.resolveOwnerLayer) refuses a second cache for this reason. This package already reads per call where the definition decides:declaresActivityStampColumnson everysys_activityinsert, andcurrentMilestoneWatchedon the read side.Tests
New
audit-writers.definition-follows-metadata.integration.test.ts, on the same real composition as the probe. Every definition change goes throughprotocol.saveMetaItem, and there is no restart:enable: attach refused (FILES_DISABLED/ 403). Saved again withenable.files: true: the read-back showstrueand the same attach is admitted.files: true, attach admitted. Saved withfiles: false: the same attach is refused again.enable.files: truefrom the start, never looked up before: admitted.passwordfield added through the door, then the record updated. Thesys_audit_logrow'snew_valuecarries the mask (SECRET_MASK), never the value.Ablation (on committed
56218f119, throughscripts/ablation-replace.mjsin wrap mode with a restore trap): the held copy was put back intogetObjectDef. The anchor hit once (1 to 0), and the blob went frome5840a3a6b5bto8bfd1f9e7ff2. Cases 1, 2 and 4 went red; control 3 stayed green:expected { code: 'FILES_DISABLED', status: 403 } to deeply equal { admitted: true }expected { admitted: true } to deeply equal { code: 'FILES_DISABLED', status: 403 }The restore was proven by the tool: blob after restore equals the HEAD blob, and
git diff HEADis empty.Local verification (head
30a57ba9d)pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2(whole package, on56218f119): 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, andpnpm --filter @objectstack/plugin-audit typecheckexited 0, withcheck:test-typecheck: OKand 0 errors.turbo run build --filter=@objectstack/plugin-audit^..., 15/15), then the package itself. All of it ran underscripts/pm/os-verify-lock.sh.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(70 commands) was rerun in full on30a57ba9d. All 70 exited 0.dispatch-gates --ranreads:70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN.56218f119,check:query-options-erasurewas red: the test surface grew from 236 to 237 sites because the new test passed its option bagsas any. They are now typed withsatisfies, and the surface is back at 236.check:dual-build-cjs-loadsandcheck:i18nexited 3 (PREREQUISITE NOT MET: no workspacedist/). Both measured green on the final head.test:repoproject and the full CI farm are left to CI.Acceptance notes
@objectstack/metadata-core'screateFieldPresenceProbe(field-set cache) andcreateResolver(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 aboutsys_user,sys_audit_logandsys_activity, and about the audited object's organization column. They sit outside this card's file surface and were left untouched.sys_attachmentin the test is a stand-in under the real name (service-storage owns the definition). The gate reads onlyparent_object.Generated by Claude Code