Skip to content

fix(service-settings): an unmounted audit ledger is a configuration, not a fault - #18703

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-18368-unmounted-ledger-is-configuration
Sep 17, 2026
Merged

huangyiirene merged 2 commits into
mainfrom
claude/issue-18368-unmounted-ledger-is-configuration

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18368

Clause-②: no — no new key on a published payload. No symbol is added to any export, src/index.ts is untouched, and CONFIG_CHANGE_ACTION / CONFIG_CHANGE_OBJECT_NAME keep their shapes; the new AUDIT_LEDGER_OBJECT_NAME and the debug? member are module-local. Re-verified against the diff by the reviewing seat, ⛔ not taken from the report.

buildConfigChangeAuditSink attempted the sys_audit_log config_change insert
unconditionally and reported the throw it got back as a degradation. A host that never
mounted the OPTIONAL @objectstack/plugin-audit — objectstack serve --preset minimal,
an EE host that mounts no audit, a hosted tenant kernel — has no ledger to write to,
so that report described a deployment behaving exactly as composed. An unmounted ledger
is a configuration, not a fault.

The sink now probes the engine registry for sys_audit_log before the write. Absent, it
skips at debug and attempts nothing.

The premise, re-taken — and one half of it is FALSE on this tree

The card (and the repo:cloud seat's reading behind it) says the sink "logs an ERROR
on every tenant settings write". Measured on origin/main at the branch point
1bc22b3, neither half of that severity holds in this repository, and the roster below is
how it was measured rather than recalled:

the card's claim measured here where
level is ERROR warn the sink's own catch preferred logger.warn over logger.error
once per write once per process failureReported has guarded that report since #8145

⭐ What IS per-write, and what the card was almost certainly hearing, is one frame down:
the insert this sink should never have attempted reaches ObjectQL.insert, whose terminal
catch logs Insert operation failed — at warn since #17052, and with nothing
reported-once about it. Probed directly against a real ObjectQL with sys_audit_log
absent from the registry:

=== sys_audit_log NOT registered, driver throws "no such table" ===
THREW: Error: no such table: sys_audit_log
  [debug] x2   Insert operation starting / No hooks registered for event
  [warn]  x1   Insert operation failed

⛔ The defect the card names is real and this PR fixes it; only the LEVEL and the
FREQUENCY in its prose are wrong for this tree. The cloud seat's readings were taken
against a published @objectstack/service-settings on a hosted plane, and this session
cannot reach cloud#1975 / cloud#1963 to reconcile the two — that is declared, not
verified. The fix does not depend on which is right: skipping the write removes BOTH lines,
and the acceptance criterion ("no ERROR") holds under either reading.

Both arms, and neither is optional

The quiet arm alone would pass just as well on a sink whose log line had been deleted
outright, so every case carries its opposite.

  • QUIET ARM — registry has no sys_audit_log: the settings write lands, the
    sys_setting_audit row lands, no insert is attempted (that is what silences the
    engine's per-write line, and it is asserted on the seam rather than on the log), nothing
    on error, nothing on warn, and exactly one debug line naming the remedy.
  • CONTROL ARM — ledger IS mounted and the insert genuinely fails: the insert WAS
    attempted, and the report still fires, on error, carrying its consequence and its fix.

Three more properties, each of which a plausible wrong implementation would break:

  • the probe records nothing and is re-taken per call — a ledger registered later in
    the same boot starts recording. A memoized "not mounted" would be a verdict the same
    boot can contradict (AGENTS.md → Startup registry reads), and it would pass every
    other case in the file.
  • "cannot tell" is not "absent" — getSchema is an ObjectQL member, not an
    IDataEngine one. An engine that does not carry it still gets the write attempted,
    which is the pre-change behaviour. Read the other way, this sink would go permanently
    silent on every lean host engine: the same defect, inverted.
  • the warn fallback survives the level flip — a sink declaring only warn still
    gets the line, spelled as a property access so a class-based Logger keeps its receiver.

Why the fault arm got LOUDER

What is left in the catch once the expectation is skipped is a mounted ledger whose
insert genuinely failed: the settings write is on disk and claims to be audited while the
compliance row is not, and nothing retries it. That is AGENTS.md → Degradation log
levels
to the letter, so the report moved from warn-first to error-first. This is the
card's own control arm ("the ERROR still fires"), and it is why the level claim above
being false did not make it unimplementable.

⛔ The seam is NOT added to DURABILITY_CRITICAL_CALLEES: the callee here is the generic
eng.insert, and that script's header is explicit that a name that broad trades a miss
for a false-positive rate that gets gates disabled. Noted below, not filed.

Ablation — both new arms proven able to fail

Run from the committed state, each leg proving the mutation reached disk (blob hash moves
off the HEAD blob) before the tests ran, and each restored by git checkout HEAD -- path
with the restore proven by hash equality, not by an exit code.

leg mutation result
A the registry probe removed (if (false)) 2 failed — QUIET ARM, and the non-memoization pin
B the fault report put back on warn-first 2 failed — CONTROL ARM, and the unanswerable-engine pin

Restored blob 2f9535bb both times, equal to HEAD:packages/services/service-settings/src/config-change-audit.ts.

Verification

Commands below ran at 5ec4b17, the final commit.

  • pnpm --filter @objectstack/service-settings test — 33 files, 584 tests, all pass
    (16 in config-change-audit.test.ts, 5 of them new).
  • pnpm --filter @objectstack/service-settings typecheck — clean; tsc --listFiles
    confirms the program reaches all 33 *.test.ts in the package, this file included, so
    the green covers the new cases.
  • pnpm --filter '@objectstack/service-settings^...' build — dependency closure, clean.
  • eslint . --no-inline-config --format json — the WHOLE repo population, not a narrowing:
    6819 files, 0 errors, 0 warnings.
  • Gate roster derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands
    (no paths — merge-base derived), reconciled with --ran:
    59 derived, 57 run and green, 2 NOT MEASURED, 0 unrun. The two are
    check:dual-build-cjs-loads and check:type-check-debt, both exit 3
    (PREREQUISITE NOT MET — each needs a full workspace build, which CI performs); ⛔ an
    exit 3 is recorded as not-measured, never as a pass and never as a finding.
  • Beyond the derived roster, the two families this change most plausibly implicates were
    run by hand and are green: pnpm check:durability-log-level (36 seams, all loud) and
    pnpm check:startup-registry-verdict (43 seams, none recording a contradictable verdict).
  • Two gates went red on the first pass and were FIXED, not routed around:
    check:doc-authoring (a tracker id had been spelled inside the operator-facing string)
    and check:tenant-audit-census (see the acceptance note below).
  • check-plugin-teardown-shape --self-test first exited 3 on a fixture commit this shallow
    clone could not reach; resolved with a targeted git fetch of that commit, then green.
    ⛔ Not a finding about this diff.

Not merged with origin/main (3 commits ahead: packages/spec contracts, a spec docs
page, and scripts/pm/check-half-states.mjs) — zero path intersection with this diff,
so there is no overlap for §10's joint-breakage re-check to scope onto. The merge queue
validates the rebuilt generation regardless.

Acceptance notes

Noted, not filed — none is a reproducible defect, a declared-contract violation or a
metadata-authoring trap:

  • The ledger object name is spelled inline at its two pre-existing sites, deliberately.
    Folding makeFieldProbe's getSchema call and the insert onto the new module constant
    moved a corpus-scale ratchet — content/docs/permissions/tenant-audit-census.mdx, "object
    name spelled inline" 109 to 108 and "named through a const" 40 to 41 — plus a hand-written
    prose count in a docs tree, and this card's declared file surface is
    packages/services/service-settings/src/. The consolidation is worth doing; it is worth
    doing on a card that owns the census regeneration. Successor: whoever next touches this
    census. The constant carries the reason at its definition.
  • This durability seam is invisible to check:durability-log-level, because its callee
    is the generic eng.insert rather than a named persistXxxRow helper of the kind every
    other entry in DURABILITY_CRITICAL_CALLEES names. Extracting one and declaring it would
    edit scripts/, outside the declared surface. Successor: none currently queued — noted so
    the next reader does not read the gate's green as vouching for this line's level.
  • makeFieldProbe memoizes its organization_id answer for the process, which was a
    latent version of the same shape this card fixes: a "not registered" cached before
    plugin-audit finished registering would leave organization_id unstamped forever, and
    the SecurityPlugin's RLS predicate would then hide every config_change row from
    non-platform readers — the empty config_changes view, one layer down. This PR closes it
    incidentally rather than by design: the probe now returns early on an unmounted ledger, so
    makeFieldProbe is only ever consulted on a deployment where the ledger IS registered.
    Recorded because the closure is a side effect and nothing pins it.
  • Route (1) is untouched, as ruled: nothing here asks the hosted policy to force-mount
    audit, and the fix is independent of whether it ever does — --preset minimal and an EE
    host that mounts no audit reach this same line.
  • packages/spec is untouched. No exported symbol, parameter or option was added:
    buildConfigChangeAuditSink(engine, logger?), the row shape, CONFIG_CHANGE_ACTION and
    CONFIG_CHANGE_OBJECT_NAME are unchanged, so Clause-②: no still holds and the changeset
    is patch.

Dispatched by the domain:services PM seat (objectstack#6021) under the claim comment on
the card; this PR is the dev's record.


Generated by Claude Code

…not a fault

`buildConfigChangeAuditSink` attempted the `sys_audit_log` `config_change`
insert unconditionally and reported the throw it got back as a degradation.
A host that never mounted the OPTIONAL `@objectstack/plugin-audit` has no
ledger to write to, so that report described a deployment behaving exactly as
composed — and the insert it never should have attempted reached
`ObjectQL.insert`, whose own terminal catch logs `Insert operation failed` once
per settings write.

The sink now probes the engine registry for `sys_audit_log` before the write.
Absent, it skips at `debug` and attempts nothing. The probe records nothing and
is re-taken per call, so a ledger registered later in the same boot starts
recording; an engine that cannot be asked (no `getSchema`) still gets the write
attempted, which is the pre-change behaviour.

What is left in the catch is a mounted ledger whose insert genuinely failed —
a write that claims to be audited and is not — so it reports on the `error`
channel, with `warn` kept as the receiver-safe fallback for a sink with no
`error`.

Both arms are pinned, plus the probe's non-memoization, the unanswerable-engine
path and the `warn` fallback.

Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP
Co-authored-by: Claude <noreply@anthropic.com>
…counted sites

Two gate findings from the derived roster, both on the new code:

`check:doc-authoring` — the tracker id had been spelled inside the operator-facing
failure string. A runtime string reaches authors and operators, none of whom can
resolve `#NNNN`; the id stays in the adjacent source comments, where the reader who
can resolve it is already looking.

`check:tenant-audit-census` — folding the pre-existing `getSchema` and `insert`
spellings onto the new module constant moved a corpus-scale ratchet (inline 109 to
108, const 40 to 41) and a hand-written prose count in `content/docs/permissions/`,
a tree this change has no business in. Both spellings are restored verbatim; the
constant is used only by the new mount probe, and says why.

Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings, touching 5 documentable anchor(s).

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

  • content/docs/deployment/production-readiness.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/kernel/runtime-services/audit-service.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/permissions/record-view-auditing.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/plugins/packages.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/ui/setup-app.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/releases/v14.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/releases/v17/17-0.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))
  • content/docs/releases/v17/17-1.mdx (via sys_audit_log (literal, a string literal in AUDIT_LEDGER_OBJECT_NAME))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 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.

Coarse fallback — 7 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 f8eaf670454a69ebb965d9ec94aeed31303b1f4b → packageMentionDocs.

Which tree this was computed on

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

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

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

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