Skip to content

feat(security): record platform-admin standing on the audit ledger at boot - #19194

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-18412-platform-admin-standing-audit-r2
Sep 19, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-18412-platform-admin-standing-audit-r2

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

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_EMAIL plus a restart — the product keeps no environment-variable history and an auditor cannot read one. sys_audit_log recorded 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: resolvePlatformAdminStanding builds the per-entry summary at every walled boot and bootstrap-platform-admin.ts logs it at info.

What changed

@objectstack/plugin-auditsys_audit_log.action declares 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 in ACTIONS_WITH_WRITERS, and the four generated translation bundles gain the option leaf. Its shipped surfaces are the unfiltered recent and all_events views, so the value is not an empty widget (审计面宁窄勿谎). ⛔ Deliberately NOT added to the config_changes filter — that view answers «which setting changed», and standing is not a sys_setting. ⛔ No new list view, ⛔ no new object, ⛔ no new configuration key.

@objectstack/plugin-securityplatform-admin-standing-audit.ts holds 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.ts compares 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_value state both sides of the delta, and old_value is null on the baseline row and only there.

The single posture 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_id is NULL, and ⛔ it is not a gap to repair

Recorded 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: every sys_audit_log field is readonly: true and validateRecord skips 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_organization row 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 stamping tenantId ?? 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

Evidence

No new engine write call site. The ledger insert routes through this file's existing tryInsert door rather than a second ql.insert beside it — check:tenant-audit-census stays flat at 227 certified write call sites (a first draft that spelled its own ql.insert was 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's update() now opens with assertEngineUpdateDispatch), check:where-matcher (the double's where now REFUSES the combinators it does not implement), and check:tenant-audit-census above. check:i18n, check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3, ⛔ not a pass); they were re-run green after turbo run build over the whole workspace, 72/72 tasks.

Tests. @objectstack/plugin-security 114 files / 2221 tests passed; @objectstack/plugin-audit 24 files / 353 tests passed; typecheck green on both. Repo-wide pnpm lint green at a228d7452 (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 with blob == HEAD and an empty git diff HEAD.

leg mutation result
the NULL-organization exception undone row.organization_id = null becomes 'org_platform' 2 failed / 13 passed — the exception pin and the first-boot baseline pin
change detection always reports a change previousSerialized !== nextSerialized becomes true 2 failed / 13 passed — the restarted-rig pin and the explicit-null pin
a refused read conflated with «no prior row» the refusal observer swallows instead of recording 1 failed / 14 passed — the refusal pin only
the action value leaves the enum, its writer stays delete the enum member 1 failed / 8 passedthe action enum declares exactly the actions that have a writer

The 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

🤖 Generated with Claude Code

https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF


Generated by Claude Code

… 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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 19, 2026
@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-audit, @objectstack/plugin-security, touching 35 documentable anchor(s).

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d402e328552622e3b421d567e793d4b77c324c44.

8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 34 pages), organization_id (literal, 31 pages), sys_user (literal, 36 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 20 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 d402e328552622e3b421d567e793d4b77c324c44packageMentionDocs.

Which tree this was computed on

This run read content/docs from b8eafde0debcc1469c44c7cd7f345bfab5af23eb — the merge of head 0a04b88918cd13690b890105def3676b19c87cd3 into base d402e328552622e3b421d567e793d4b77c324c44, 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 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

⚠️ 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 d402e328552622e3b421d567e793d4b77c324c44 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… 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
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Config-derived platform-admin standing leaves no durable record of who held it and since when — boot already computes it and throws it into a log line

2 participants