feat(security): record platform-admin standing on the audit ledger at boot - #19194
Conversation
… boot Config-derived platform-admin standing left no durable record of who held it and from when. The boot already computed the per-entry summary and threw it into a log line; it is now written to the existing audit ledger as one entry per CHANGE of standing, plus the first-boot baseline. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
… and add the changeset The new double's `update()` now opens with `assertEngineUpdateDispatch` and its `where` matcher refuses the combinators it does not implement; the boot's unchanged-standing line drops the tracker id from the runtime string. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
📓 Docs Drift CheckThis PR changes 2 package(s): 31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 20 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 b8eafde0debcc1469c44c7cd7f345bfab5af23eb && git checkout b8eafde0debcc1469c44c7cd7f345bfab5af23eb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d402e328552622e3b421d567e793d4b77c324c44 0a04b88918cd13690b890105def3676b19c87cd3 && git checkout -B drift-repro d402e328552622e3b421d567e793d4b77c324c44 && git merge --no-ff 0a04b88918cd13690b890105def3676b19c87cd3
node scripts/docs-audit/affected-docs.mjs --json d402e328552622e3b421d567e793d4b77c324c44
|
… named _object `tsconfig.test.json` runs with `noUnusedParameters`, and the engine-contract patch introduced `update(object, data, options)` whose first argument the double does not read — TS6133 in the test project only, which a src-only `tsc --noEmit` cannot see. The parameter keeps its POSITION, because the object name is argument one on every IDataEngine write door and dropping it would hand the dispatch predicate the wrong arguments. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Fixes #18412
Clause-②: no
The walled boot records platform-admin standing on the existing audit ledger, so «who held administrator standing, and from when» survives the move off the stored grant row.
What was lost, and where it went
Platform-admin standing moved from a stored grant row to config-derived, request-time resolution (#11663 re-anchor, ADR-0131). The row carried its own history; config carries none. After the migration the only trace of a grant or a revocation was a change to
OS_PLATFORM_OWNER_EMAILplus a restart — the product keeps no environment-variable history and an auditor cannot read one.sys_audit_logrecorded the ACTIONS all along; what had no writer at all was the basis of the authority behind them.The answer was already being computed and thrown away:
resolvePlatformAdminStandingbuilds the per-entry summary at every walled boot andbootstrap-platform-admin.tslogs it atinfo.What changed
@objectstack/plugin-audit—sys_audit_log.actiondeclares one new value,platform_admin_standing_change, WRITER-FIRST (the only way a value is allowed onto that enum, #8147 / #8315). Its writer is named inACTIONS_WITH_WRITERS, and the four generated translation bundles gain the option leaf. Its shipped surfaces are the unfilteredrecentandall_eventsviews, so the value is not an empty widget (审计面宁窄勿谎). ⛔ Deliberately NOT added to theconfig_changesfilter — that view answers «which setting changed», and standing is not asys_setting. ⛔ No new list view, ⛔ no new object, ⛔ no new configuration key.@objectstack/plugin-security—platform-admin-standing-audit.tsholds the row shape and the change detector, PURE, so a pin tests the writer's row rather than a hand-written copy.bootstrap-platform-admin.tscompares the resolved standing against the last snapshot already on the ledger and writes one entry per CHANGE of standing, plus the first-boot baseline. A restarted rig writes nothing.old_value/new_valuestate both sides of the delta, andold_valueis null on the baseline row and only there.The
singleposture is untouched. It still promotes the first registrant and still writes a durable grant row; the durability this restores is walled-posture-specific.Nothing here widens who holds standing or what standing permits. The derivation site (
core/src/security/resolve-authz-context.ts§6b-config) is not in this diff. This adds a RECORD of authority, never a grant of it — a maintainer floor that is ⛔ not this lane's to move, and it was not moved.⭐ The declared exception —
organization_idis NULL, and ⛔ it is not a gap to repairRecorded in two places as the ruling requires: a block comment beside the stamp in
platform-admin-standing-audit.ts, and on the card. A third copy is a pin, because nothing else can see this: everysys_audit_logfield isreadonly: trueandvalidateRecordskips readonly fields, so a tenant id stamped here would be accepted by the entire stack.The record is deployment-level by construction. ADR-0131 §1.5 「The rejected middle: a platform organization」 considered inventing an owner for deployment-level rows and rejected it in its own words — 「it is the natural repair and the wrong one … exists only to give NULL a new name」. A tenant id would file a whole-deployment fact behind one tenant's wall; one row per organization is the fan-out §1.5 names as wrong; and the first-boot baseline settles it structurally, since it is written before any
sys_organizationrow exists at all. This follows the tree's four existing deployment-level audit writers (audit-writers.ts,read-audit.ts,auth-event-audit.ts,config-change-audit.ts, each stampingtenantId ?? null), and is the shape ADR-0131 D7 will later make structural by dropping the column. Maintainer ruling 2026-09-18, director batch #153 item 2, 「其他同意」.Three states at the ledger, because the third is the one a two-valued read loses
plugin-auditis OPTIONAL; a host that never mounted it declined to have a ledger rather than failing to write one (the [finding]buildConfigChangeAuditSinklogs ERROR on every tenant settings write when nosys_audit_logis mounted — an unmounted ledger is a configuration, not a fault #18368 lesson, one file over).errorand never fails the boot.Evidence
No new engine write call site. The ledger insert routes through this file's existing
tryInsertdoor rather than a secondql.insertbeside it —check:tenant-audit-censusstays flat at 227 certified write call sites (a first draft that spelled its ownql.insertwas refused by that gate as unplaceable, which is how the reuse was found).Gates. All 68 derived families run, all exit 0, 0 NOT-MEASURED, 0 UNRUN — derived from the real diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths passed) and reconciled with--ran, exit codes captured before any pipe. Four of them found real defects in the first draft and were fixed rather than routed around:check:doc-authoring(a tracker id inside a runtime string),check:engine-double-contract(the new double'supdate()now opens withassertEngineUpdateDispatch),check:where-matcher(the double'swherenow REFUSES the combinators it does not implement), andcheck:tenant-audit-censusabove.check:i18n,check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3, ⛔ not a pass); they were re-run green afterturbo run buildover the whole workspace, 72/72 tasks.Tests.
@objectstack/plugin-security114 files / 2221 tests passed;@objectstack/plugin-audit24 files / 353 tests passed;typecheckgreen on both. Repo-widepnpm lintgreen ata228d7452(the final commit), so no narrowing was claimed.Ablation — every new pin was watched fail, with the mutation proved on disk and the restore proved by blob equality, not by an exit code. Run through
scripts/ablation-replace.mjs; each leg restored withblob == HEADand an emptygit diff HEAD.row.organization_id = nullbecomes'org_platform'previousSerialized !== nextSerializedbecomestruethe action enum declares exactly the actions that have a writerThe first two legs are deliberately separate from the last two: each reds a different pin family, which is what makes them four measurements rather than one restated.
Acceptance notes
packages/plugins/plugin-security/src/security-plugin.tsis ⛔ NOT in this diff — The by-id write pre-image gate resolves the row under the caller's own read scope, so an app-authored widener is still dead onprivateeven once checkAuthoredRowWrite admits it #7401 and [finding] the deliberatereference_totolerance in plugin-security has no measured stored-metadata population — the prerequisite for ever narrowing it #13542 both declare it as their trigger file and both staypm:on-hold, untouched and unrelabelled.packages/specand ⛔ zerocontent/docs/releases/**, verified on the final diff.Blocked-by:line to it.scripts/engine-double-contract.pinned.jsonis outside the dispatch's declared file surface. It is the PINNED ledger, not the shrink-only exemption baseline: the gate itself printed 「New pinned coverage is GOOD … Run--writeand commit」, and the regeneration reported 「0 added or grown, 0 lost」 in seam terms. Named here because a surface note belongs in the open, not because anything was adjudicated.sys_audit_logwriters still comment thatorganization_id「only exists in multi-tenant deployments」, whichspec/src/data/injected-system-columns.tscontradicts — the column is injected unless the object declaressystemFields.tenant: falseortenancy.enabled: false. Behaviour is unaffected because every writer probes the schema first, so it is a wrong comment rather than a defect. Successor: the ADR-0131 D7 execution card forsys_audit_log. Carried forward fromissuecomment-5717560616; ⛔ not re-measured here and ⛔ not widened into this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Generated by Claude Code