Skip to content

fix(security): make a refused RBAC catalog write boot-visible instead of a silent seed of zero - #12967

Merged
os-litant merged 10 commits into
mainfrom
claude/issue-12923-rbac-seeder-silent-unique-violation
Aug 29, 2026
Merged

fix(security): make a refused RBAC catalog write boot-visible instead of a silent seed of zero#12967
os-litant merged 10 commits into
mainfrom
claude/issue-12923-rbac-seeder-silent-unique-violation

Conversation

@os-litant

@os-litantos-litant commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12923

The defect

The five RBAC catalog seeders answered a refused write with null/false, which is
byte-for-byte the answer for "nothing to do": the seeded counter never incremented and
the pass returned normally. On a deployment still enforcing a platform-wide unique
index on the name column — the shape that predates per-organization materialization —
every per-organization INSERT is refused that way, so the boot log read as a successful
seed of zero rows. Measured on a deployed plane and undetected for weeks: an empty
Setup (no positions, no permission sets, no capabilities) under a clean log.

Re-verified independently on this branch's merge base rather than inherited:

FileWhat was there
bootstrap-declared-positions.tstry { return await ql.insert(...); } catch { return null; } / catch { return false; }
bootstrap-builtin-positions.tsbyte-similar copies of both
bootstrap-platform-admin.tssame shape, multi-line
permission-set-projection.tsthe exported copy that bootstrap-declared-permissions.ts and bootstrap-declared-capabilities.ts both import

Each file declares a SeedOptions.logger in the same file, and neither tryInsert nor
tryUpdate accepted or used it.

The outer handler was not missing — it was disarmed

security-plugin.ts already wraps seedCatalogForOrganization in a try/catch that
warns, and that catch is unreachable for this failure class: the refusal is
converted to null three call layers below, so the await resolves normally and the
hook logs "RBAC catalog seeded" at info over a seed of nothing. Adding another outer
try/catch fixes nothing. The signal has to survive the inner helper, which is where
this change is.

The shape follows the honesty precedent already in that file: the resync path returns
success: false with a reason string when applied === 0 rather than a cheerful
zero.

The change

per-organization-catalog.ts — the existing shared home for this family's cross-cutting
concerns — gains createSeedWriteRefusals() / reportSeedWriteRefusals(), beside
warnOrganizationLessRows and built on the same aggregate-then-report discipline. Each
seeder's catch now records before it answers, and the pass reports one actionable
line per object per class
, beside its counts. The three copies did not become four.

Two classes, deliberately never merged:

  • a unique violation is named as a deployment-schema defect, with the migrate remedy
    (os migrate plan, where the legacy index surfaces as a replace_unique_index
    operation, then os migrate apply);
  • anything else gets its own line and is never relabelled as the above, because no
    migration repairs it. Sending an operator to os migrate for an unreachable database
    is a confident wrong answer.

Classification is the shipped cross-dialect predicate (isUniqueViolationError /
uniqueViolationColumn from @objectstack/types, the same pair objectql/src/engine.ts
imports), never a local 23505 / ER_DUP_ENTRY regex.

Diagnosis only — loud, never fatal. A rethrow would turn a silent degradation into a
boot failure on every deployment carrying the legacy index. Counts, accept/reject
behaviour and the healthy-path logs are unchanged; a pass that refuses nothing stays
silent, and nothing new is exported from the package index.

The two classes take different LEVELS, and the split is the rule's own

AGENTS.md "Degradation log levels" decides this with one question — after the
degradation, does the system still look normal from the outside while something it claims
is persisted has not actually landed?

SeedLogger gains an optionalerror carrying the kernel Logger arity
(message, cause?, meta?, matching the sibling ProjectionLogger and
Logger.error in @objectstack/spec/contracts, so meta cannot land in the cause slot).
Every emission goes through one named helper, logSeedDurabilityFailure, so no site can
forget the fallback:

  • not logger?.error?.(...) — that prints nothing against a host that injected a
    reduced sink, silently dropping the loudest line in this change;
  • not (logger.error ?? logger.warn)(...) — that evaluates to a bare function and calls
    it with this === undefined, which @objectstack/core's class-based ObjectLogger
    does not survive.

Both prohibitions and the exact if/else spelling are the measured conclusions already
recorded on SqlDriver.logDurabilityFailure; this mirrors them.

⚠️The call shape alone was not enough — the TYPE had to change too. With warn also
optional on SeedLogger, {} satisfied the type and every value of it was permitted to
print nothing, so the fallback was a convention rather than a guarantee and no call-site
spelling could repair it. warn is now non-optional; error stays optional so
reduced sinks remain representable. (⛔ Not the other two repairs: making error required
forecloses those hosts, and a required info would let a lost write be reported at info
— the reassuring half-truth the degradation rule exists to remove.) Verified rather than
assumed: the package source typechecks clean with warn required, so every one of the
five seeders' construction sites already carried it — no as cast was added anywhere, and
nothing was widened.

SeedLogger, logSeedDurabilityFailure and the reporter remain unexported from the
package index
src/index.ts is byte-identical to origin/main.

The reporter is renamed warnSeedWriteRefusals to reportSeedWriteRefusals, because
a function named warn* that emits error for one of its two classes misdescribes
itself. Mechanical: five call sites and the tests.

On echoing driver text — what I found, since the card asked

Two corrections to the framing I was dispatched with, both measured in-tree:

  1. driver-fault-redaction.ts governs the server LOG, not the REST boundary. Its own
    header: "This is a server LOG. The rethrown error is untouched and every HTTP boundary
    is unaffected."
    A boot log is that boundary. A SQL driver builds its message by
    prefixing the fully bound statement — every value inlined — to the database's
    diagnostic, so printing it from the seeder would re-open exactly the exposure that
    redaction was written to close. The warning therefore reads only the value-free
    code/errno channel, and a test plants a canary in the bound statement and asserts
    it cannot reach the log through this line.
  2. uniqueViolationColumn does not answer "name the colliding index" — by ruling. It
    returns undefined whenever the dialect named an index rather than a column
    (maintainer ruling, 2026-08-08: an index name mistaken for a column is worse than no
    answer), which is the usual case for this defect. It is still called, so SQLite's
    column-naming spelling is reported when it appears, and nothing is guessed when it
    does not.

So the colliding index is named where it already is, safely: the query engine logs every
one of these refusals at ERROR with redactBoundStatement applied, and that redaction
deliberately keeps the identifier-bearing tail so an operator debugging a duplicate
can read the index name. The seeder's aggregate line points at those entries instead of
re-deriving them. That leaves one open question for the maintainer, recorded in the dev
report rather than guessed at here: whether a shipped index-name accessor should exist at
all is a contract decision sitting next to the 2026-08-08 ruling, and I did not invent one.

I also checked for a ruling against boot-time warnings. types/src/unique-scope-install-gate.ts
carries one — "Never a boot-time warning (#4884 discipline)" — and it does not reach
here: that rule refuses a gate derived from declarations, which would fire on every
boot of every deployment forever. This line is evidence-driven and fires only when a write
was actually refused, so a healthy deployment stays silent. Pinned by a test.

driver-sql's legacyUniqueReplacements is untouched — it is the already-shipped
operator-side half of this story, the counterpart rather than the target.

Merged with main — both changes kept

db39dfc1c (#13065, verified platform owner bypasses the Layer 0 org wall) landed on
main mid-round and touches bootstrap-platform-admin.ts, the one file this card also
edits. Exactly one conflicted file; scripts/engine-double-contract.pinned.json
auto-merged and was not hand-edited.

The conflict was purely import adjacency — both sides added an import after
claim-seed-ownership.js — so both sides are kept. Their
matchesDeclaredOwnerEmail import and its call site are intact and the inline
toLowerCase() compare is not restored (0 occurrences), so nothing about that
security change is un-landed here. Verified in both directions rather than by eye:

Merged with a merge commit — never a rebase, amend or force-push, so any other checkout of
this branch stays valid. The branch now merges cleanly with origin/main
(git merge-tree produces a tree with no conflict), and the derived change set is still
exactly this card's 10 paths, now against merge base db39dfc1c.

Tests

New pins in seed-write-refusal.test.ts (24 cases). Error spellings are copied from the
classifier's own live-server fixtures, never invented:

  • a refused INSERT produces the diagnostic and the pass returns rather than throwing;
  • the diagnostic is aggregated — 40 refusals in one pass produce one line carrying
    the true count, not 40;
  • a non-unique-violation refusal is not reclassified, gets its own line, and the
    migrate remedy is absent from it;
  • an absence sentence (PostgreSQL 42830, where the words "unique constraint" are adjacent
    and the sentence says there is none) stays in the other class;
  • the green path reports its counts unchanged and says nothing about refusals;
  • the bound statement and its values never reach the log.

The level split is pinned both ways, and so is the fallback:

  • a unique violation reaches the error sink and neverwarn, through the real
    seeder as well as through the helper;
  • a non-unique refusal reaches warn and nevererror;
  • a host that injected onlywarn still receives the unique-violation message whole,
    remedy included, through the fallback — the case a bare logger?.error?.(...) would
    answer with silence;
  • the error line carries both halves the rule asks for (the consequence and the fix);
  • the cause slot stays undefined and the detail travels in meta, since a summary of N
    refusals has no single cause.

And the guarantee itself is pinned, by reading the declaration's own AST: warn is
non-optional, error and info are optional, the pin fails loudly if it ever stops
finding SeedLogger, and the reduced sink is not merely representable but actually
served. It survives removal of check:optional-error-sink-contract, which is the point —
that gate found the hole, but the property belongs to this module.

⚠️Why an AST read and not a @ts-expect-error. Measured, and it cost two attempts.
This package's tsconfig.json excludes **/*.test.ts, and tsc --noEmit --listFiles
reports zero plugin-security test files in the program its typecheck script runs.
check:type-check-coverage refuses exactly that shape by name — "carries a
@ts-expect-error directive but no tsc program the typecheck script runs compiles it …
replace the pin with a runtime assertion"
, with PHANTOM_PIN_DEBT closed to new entries
— and it refused this file when the pin was first written that way. The runtime assertion
is the remedy the gate itself names. It is seeded from __dirname rather than
import.meta.url, because under module: NodeNext this package resolves as CommonJS
where import.meta is TS1470 — which pushed the shrink-only TEST_DEBT ratchet from 11
to 12 before it was fixed. Both were caught locally by re-measuring, not in review.

Ablation

Two ablations, both on the merged tree, both confirming the mutation on disk by
git hash-object (never an editor's exit code) and restoring with
git checkout HEAD -- ABSOLUTE_PATH under a trap ... EXIT INT TERM with an absolute
repo root, verified by hash equality plus an empty git diff HEAD and an empty
git status --porcelain.

1. The repair itself — revert the two recording catch bodies in
bootstrap-declared-positions.ts to their pre-fix form. Re-run on the merged tree,
because a reading taken on a different tree is not a reading about this one:

HEAD_BLOB = 10e2875383892727a2ea1df65c102aca25bc0332
PRE_HASH = 10e2875383892727a2ea1df65c102aca25bc0332 (asserted equal before mutating)
POST_HASH = 98c8c518f4f757060be3ba7b5d6ca507471f0bdc
deleted-marker 1 -> 0 · injected-marker 0 -> 1 (both anchors, both directions)
ABLATED_VITEST_EXIT=1 · Tests 5 failed | 19 passed (24)

The same five cases go RED as before the merge — the four that drive
bootstrapDeclaredPositions plus the "reaches the durability channel through the real
seeder" pin written to prove the split is not helper-only. The green count moved 14 to 19
only because the pin file grew from 19 cases to 24. The cases exercising the helper
directly, the built-in-position pass (a different file) and the green path stay GREEN. No
rebuild was required and none is claimed: the test reaches the mutated file by
relative import inside the same package, so vitest resolves it from src/, not from
any dist/ — and had the resolution path been otherwise the ablation would have stayed
green, which is the reading that would have voided it.

2. The type guarantee — put the ? back on SeedLogger.warn. The merge left both
ablation subjects byte-identical (per-organization-catalog.ts and
bootstrap-declared-positions.ts have the same blob hashes before and after), but this
was re-measured on the merged tree anyway rather than re-quoted:

HEAD_BLOB = 16868ccfc38f9eb2a57f0c7d39a617bb8c008673
PRE_HASH = 16868ccfc38f9eb2a57f0c7d39a617bb8c008673 (asserted equal before mutating)
POST_HASH = 19618bb86b63c3aa264928f3d78e0afdb2d2bed8
deleted-marker 1 -> 0 · injected-marker 0 -> 1
ABLATED_VITEST_EXIT=1 · Tests 1 failed | 23 passed (24) — exactly one case red:
× declares `warn` NON-optional — so no value of the type can be silent
ABLATED_GATE_EXIT=1 · check:optional-error-sink-contract reproduces CI verbatim:
`error` is optional and `warn` is optional too — every value of this type may print nothing

So the pin is precisely targeted (one case, not a cascade), and the same mutation
reproduces the CI failure locally — which is how this fix was verified to be the one that
closes it rather than merely adjacent to it.

⚠️ Worth recording, because it nearly became a false reading: the first re-run of this
ablation reported a clean 11 and no red at all. The script was measuring through tsc,
which was the right instrument for the @ts-expect-error shape this pin used to have
and the wrong one for the runtime AST assertion it has now. The stale instrument, not
the tree, produced the green. Fixed and re-run above.

Gates

All run on the merge commit b8651025a, exit codes captured before any pipe.

GateIts own judgment line
@objectstack/plugin-security vitestTest Files 89 passed (89) · Tests 1619 passed (1619)
package typechecktsc --noEmit && tsc --noEmit -p tsconfig.scripts.json, exit 0
pnpm lint (repo-wide, not narrowed)exit 0, no findings
check:optional-error-sink-contract✓ optional-error sink contract: every sink declaring an optional error guarantees a warn channel (1 baselined, shrink-only) — the 1 baselined entry is pre-existing, not this diff
check:engine-double-contractOK — 705 pinned, 134 in the DEBT ledger, 3 exempt — this card's double is one of the 705 pins, not a baseline exemption
check:where-matcherwhere-matcher conformance holds: 314 matcher(s) discovered, 314 answer the combinator battery correctly or refuse it loudly
check:objectql-double-limitObjectQL double limit conformance holds: 291 double(s) graded, 91 apply the caller's bound or refuse it loudly
check:type-check-coverageOK — 65/78 workspace packages type-checked (plus the root)
check:type-check-debt (--re-measure)OK — 31 ledger entr(ies) re-measured in 243.7s, 1570 raw tsc error(s) total, none above its recorded number · surplus: none
check:i18nOK (9 package(s) — all bundles in sync, no undeclared authoring keys)
check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:published-files, check:slot-lookup, check:page-declaration-shape, check:nul-bytes, check:query-options-erasure, check:i18n-stale-fill, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-half-statesexit 0
check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-ci-filter-parity, check-comment-mask-adoption, check-plugin-teardown-shape, release-rehearsal-clone --self-testexit 0

NOT MEASURED, not folded into the green list:

  • ⚠️check:durability-log-level exits 0 over this file, and that is NOT an endorsement
    of the level.
    The gate is deliberately narrow: it judges a catch whose try calls an
    operation named in its declared DURABILITY_CRITICAL_CALLEES vocabulary. That vocabulary
    is 18 specific function names (syncSchema, writeRecord, persistAuditTrailRow,
    …) and ql.insert is not among them, so these seeder seams sit outside the gate's
    reach
    . Its green here means not measured for this site, never level approved. The
    level was chosen against the AGENTS.md rule directly, not against this gate.
  • scripts/pm/check-half-states.mjs — exit 3, PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential. Its own text: "Nothing was swept ... it is
    no reading at all."
    A board gate, unrelated to this diff.

Would adding these seams to that vocabulary be cheap and shrink-only? Measured: no, so it
is not done here.
The vocabulary matches callee names, and the gate's own header
already excludes find/findOne/count as "too generic to declare repo-wide". insert
is in that category: 156 non-test .insert( call sites across packages/. And 15 non-test
files outside this card's five seeders still declare the same swallowing
catch { return null; } shape, so every one of them would redden at once — with nowhere
to park them, because scripts/durability-degradation.baseline.json is shrink-only and
deliberately empty, its header stating that an entry means "a REAL degradation that is
not yet fixed". Admitting 15 would assert 15 real unfixed degradations nobody has reviewed.
Widening the gate is its own card; flagged rather than attempted.

Four gate findings during the run were real and are fixed in the diff rather than
baselined: the new test double ignored the caller's limit, read a combinator as a field
name, and declared update() without routing through assertEngineUpdateDispatch; and
SeedLogger declared an optional error with no guaranteed fallback channel. The
engine-double-contract ledger entry added is a pin ("pinned": 1), not a baseline
exemption, and nothing was added to any shrink-only baseline.

Three prerequisite refusals were hit and cleared rather than reported as results, because
"could not run" is not a pass: check:i18n (workspace CLI not built) and
check:type-check-debt twice — first for an unbuilt @objectstack/service-knowledge, then,
after the merge, for @objectstack/plugin-security itself, which the ^... dependency-closure
build deliberately excludes (--re-measure cannot run: 1 workspace dependenc(ies) … still have a type entry point OLDER than their own sources). Each was rebuilt and re-run to a real
reading, and only the real readings are quoted above.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 27 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx(via sys_permission_set (literal))
  • content/docs/permissions/authorization.mdx(via bootstrapDeclaredCapabilities (symbol), bootstrapDeclaredPermissions (symbol), bootstrapDeclaredPositions (symbol), upsertPackagePermissionSet (symbol), sys_capability (literal), sys_permission_set (literal), sys_position (literal))
  • content/docs/permissions/capabilities.mdx(via bootstrapDeclaredCapabilities (symbol), sys_capability (literal))
  • content/docs/permissions/delegated-administration.mdx(via sys_permission_set (literal), sys_position (literal))
  • content/docs/permissions/permission-sets.mdx(via sys_capability (literal), sys_permission_set (literal))
  • content/docs/permissions/positions.mdx(via sys_position (literal))

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

  • content/docs/releases/index.mdx(via sys_position (literal))
  • content/docs/releases/v12.mdx(via bootstrapDeclaredPermissions (symbol), sys_permission_set (literal))
  • content/docs/releases/v13.mdx(via sys_permission_set (literal), sys_position (literal))
  • content/docs/releases/v14.mdx(via sys_position (literal))
  • content/docs/releases/v15.mdx(via sys_capability (literal), sys_permission_set (literal), sys_position (literal))
  • content/docs/releases/v17.mdx(via sys_capability (literal), sys_permission_set (literal), sys_position (literal))

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
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 14 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 db39dfc1c9b09681474475cea6e77537ab2c89abpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 75682f39fca8fa5ed0bf80854d867d849cff4ab5 — the merge of head b8651025a4ba82c2baefc6a31ec4ff066a727d56 into base db39dfc1c9b09681474475cea6e77537ab2c89ab, 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 75682f39fca8fa5ed0bf80854d867d849cff4ab5 && git checkout 75682f39fca8fa5ed0bf80854d867d849cff4ab5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db39dfc1c9b09681474475cea6e77537ab2c89ab b8651025a4ba82c2baefc6a31ec4ff066a727d56 && git checkout -B drift-repro db39dfc1c9b09681474475cea6e77537ab2c89ab && git merge --no-ff b8651025a4ba82c2baefc6a31ec4ff066a727d56
node scripts/docs-audit/affected-docs.mjs --json db39dfc1c9b09681474475cea6e77537ab2c89ab

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

@os-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT-pending, one patch round in flight. ⛔ Not a REWORK.

PM seat domain:services, session session_0194kbQJxUvv2yvsGRtuXpP5. Verified against the diff and the tree, not the report.

⚠️ Two corrections — both to MY dispatch order, both measured by the dev, both material

I asserted two things confidently in the dispatch brief. Both were wrong, and I re-verified the dev's corrections independently rather than accepting them.

1. I said driver-fault-redaction.ts governs the REST boundary, and that a boot log is therefore not that boundary. Its header, verbatim:

[#8682] Keep the CALLER'S VALUES out of the **server log** when a driver-level write fault is logged.

— and its worked example is literally an ERROR Insert operation failed {…}log entry, with planted canaries showing the row's values, the organization id and the acting user id all landing on disk. A boot log is precisely the surface #8682 closes. ⇒ Had the dev followed my instruction and echoed driver text into the new warning, it would have re-opened that exposure. The value-free code/errno channel it built instead (per-organization-catalog.ts:404-405, bounded down cause) is the correct design, and the planted canary in the bound statement is the right way to prove it.

2. I said uniqueViolationColumn is "what answers the card's naming the colliding index". It is not, by design. From its own doc:

If MySQL must name columns, the answer is a schema lookup of the index — a different, wider contract — not a guess in this function.

Composite keys return undefined for the same stated reason. ⇒ A warning built on my framing would have named nothing in exactly the case this card is about. The dev's option A — point the operator at the query engine's own already-redacted Insert operation failed entries, whose redaction deliberately keeps the identifier-bearing tail, and add no new contract — is right. Its option B (a uniqueViolationIndex export) sits directly beside a maintainer ruling that withheld index names from that module and correctly was not taken.

⭐ Five sites, not three — my brief undercounted

I named three copies of the swallowing helper. The dev found five: the three local ones plus the exported pair in permission-set-projection.ts that bootstrap-declared-permissions and bootstrap-declared-capabilities both import. Corroborated by the diff — permission-set-projection.ts is in the changed set and my order never mentioned it.

Fences — all held, verified in the diff

FenceReading
⛔ No packages/spec0 files ⇒ clause-② no holds
⛔ Nothing new exported from the package indexplugin-security/src/index.ts not in the changed set
legacyUniqueReplacements untouchedschema-drift.ts not in the changed set
⛔ No governed surfaceno docs/adr/**, no content/docs/releases/**
⛔ WARN, never THROWonly logger?.warn?.() in the new code; :503 carries a ## Why it WARNS and does not throw section
⛔ Shipped classifier, no local regex:86 imports isUniqueViolationError / uniqueViolationColumn from @objectstack/types; :83-84 explicitly rejects a local 23505 / ER_DUP_ENTRY regex
⭐ Three copies did not become fourone shared helper in per-organization-catalog.ts, beside warnOrganizationLessRows, aggregated one line per object per class

Three gate findings were real and fixed in the diff, never baselined — the new test double ignored the caller's limit, read a combinator as a field name, and declared update() unpinned. The engine-double-contract row added is a PIN, not an exemption.

The patch round: the log level, which is my pin's error

The dev raised this rather than quietly picking a side, which is the right call and the reason it is one patch round and not a rework.

My pin read "WARN, never THROW". Those are two claims and I should have separated them. "Never THROW" stands — a rethrow would fail the boot on every deployment carrying the legacy index. "warn" was me over-specifying a level against a written repo rule. AGENTS.md → Degradation log levels decides it with one question:

After the degradation, does the system still look "normal" from the outside, while something it claims is persisted has not actually landed? Yes → error.

A boot that logs RBAC catalog seeded at info over zero landed rows is that shape exactly. So: option Cerror for the unique-violation class, warn for the other. That is the split the rule's own "do not over-apply it" paragraph asks for, not an over-application.

⚠️And the green check:durability-log-level is not evidence the level was fine. The gate's header calls itself "deliberately narrow": it only inspects a try/catch whose try calls a durability-critical operation from its declared vocabulary. The seeder's ql.insert is evidently outside that vocabulary ⇒ the pass means NOT MEASURED for this site, not "level approved". Worth stating, because a reader would otherwise take the green as endorsement.

#4420 — the accident that gate exists for — is this defect's exact shape on a different table: "the durable suspended-run store was attached to a table that was never created, every write failed into a warn nobody read, and each restart silently dropped every in-flight approval. The system reported itself healthy the whole time."

The patch must widen SeedLogger with an optional error and a warn fallback (sql-driver.ts:4320logDurabilityFailure is the correct spelling in this tree) — a bare logger.error?.() against a reduced sink would silently drop the loudest line in the change.

Out-of-scope finding, worth reading

#12970 — two more swallowed tryUpdate refusals outside the catalog seed. permission-set-drift.ts drops a refused drift-diagnostic write and then gates its whole report behind updated > 0, so a boot where every write is refused prints nothing and reads as "no drift"; and permission-set-overlay-discard.ts logs "overlay discarded (sanctioned operator action)" with before/after counts that are equal — an audit record asserting an operator action that never landed. Both now have a channel to fix, since this PR's shared helper already takes an optional refusal log.


Generated by Claude Code

…ue violations, warn for the rest
The AGENTS.md degradation rule decides this: a boot that logs 'RBAC catalog
seeded' at info over zero landed rows is the shape whose answer is error.
A non-unique refusal is a retrying outage and stays warn, per the same
section's 'do not over-apply it'.
SeedLogger gains an OPTIONAL error carrying the kernel Logger arity, and every
emission routes through logSeedDurabilityFailure so the warn fallback cannot be
forgotten — never logger.error?.() (silence against a reduced sink) and never
(a ?? b)() (detached receiver).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
…llback is guaranteed by the type
With both `warn` and `error` optional, `{}` satisfied SeedLogger and every
value of the type was permitted to print nothing — a contract that permits
silence, which no call-site spelling can repair. `error` stays optional so
reduced sinks remain representable.
Pinned by reading the declaration's AST rather than a `@ts-expect-error`:
measured, zero test files in this package reach any tsc program, so an
expect-error here would evaluate never.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
…level
The earlier AST-reading pin used import.meta.url, which is TS1470 under this
package's CommonJS target and pushed TEST_DEBT 11 -> 12 on a shrink-only
ratchet. Replaced with a type-level pin that needs no filesystem: the
re-measure program DOES compile test files (it lifts the tsconfig test
exclusion), so a @ts-expect-error here is enforced rather than phantom.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
… AST assertion
check:type-check-coverage refuses a @ts-expect-error in a file no tsc program
the typecheck script runs compiles, and PHANTOM_PIN_DEBT is closed to new
entries — it named this file when the pin was written that way. Replaced with
a runtime assertion over the declaration's own AST, seeded from __dirname
(import.meta is TS1470 under this package's CommonJS resolution).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
…ac-seeder-silent-unique-violation
# Conflicts:
#	packages/plugins/plugin-security/src/bootstrap-platform-admin.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(security): RBAC catalog seeder swallows unique-violation write failures — 'seeded 0' reported as success while a legacy index vetoes every row

2 participants

@os-litant@claude