Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-security): report the two swallowed tryUpdate refusals outside the catalog seed - #13177
Conversation
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 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 80fd4424ac4bfa90e6aff99223b7e08b1c85dd5d && git checkout 80fd4424ac4bfa90e6aff99223b7e08b1c85dd5d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 596b20a6ac26c94e185f0fb36b7b5f30cb56e7d0 fc6df0b2b0dbc8ed58324268337c8d96b8d015f4 && git checkout -B drift-repro 596b20a6ac26c94e185f0fb36b7b5f30cb56e7d0 && git merge --no-ff fc6df0b2b0dbc8ed58324268337c8d96b8d015f4
node scripts/docs-audit/affected-docs.mjs --json 596b20a6ac26c94e185f0fb36b7b5f30cb56e7d0
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12970
Both sites call the shared
tryUpdateinpermission-set-projection.ts, which answersfalseon refusal — an answer byte-identical to "nothing to do". That helper already accepts an optional refusal log; neither caller passed one.The card's premise, re-verified on
origin/mainat edit timeConfirmed, with one correction the dispatching PM had already measured:
tryUpdate(permission-set-projection.ts:141-142) already takes a trailingrefusals?: SeedWriteRefusals. True.warnSeedWriteRefusals, has 0 hits repo-wide; it does not exist. The real API iscreateSeedWriteRefusals/reportSeedWriteRefusalsinper-organization-catalog.ts. The card's central claim — the channel exists, neither caller passes one — holds; only the reporter's name was wrong.updated > 0gate inrunPermissionSetDriftDiagnostics, confirmed by reading it rather than assuming: the equality gate above it means a steady-state boot writes nothing, and a wholly refused boot produced the same zero.Site 1 —
permission-set-drift.tspersistPermissionSetDriftDiagnosticscounted only writes that landed;runPermissionSetDriftDiagnosticsreported only when that count was non-zero. A boot on which every drift write was refused computed the drift correctly, persisted none of it, and printed nothing — indistinguishable from a deployment with no drift.refusedcount besideupdated;updated > 0 || refused > 0, so the line that names the drifted sets is no longer hidden behind the counter the refusal suppressed. A steady-state boot (nothing to write, nothing refused) stays exactly as quiet as before — pinned by a counter-direction test.Site 2 —
permission-set-overlay-discard.tsOn the degraded-kernel branch the resync write's result was discarded entirely. On refusal the row was re-read unchanged, so
objectGrantsAfterequalledobjectGrantsBeforewhile theinfoentry still announced a completed "sanctioned operator action": every field individually true, the entry as a whole false.The result is now read. A refused resync emits one entry stating what did and did not land — the overlay deletion, which had already succeeded, and the refused resync, with the un-healed grant count named as such — instead of the success line, never alongside it.
Emitted-with-the-failure-stated rather than withheld, deliberately: the destructive half of the operator action did land (that leg rethrows, so reaching the audit point means the overlay rows are gone). Withholding the entry would erase the record of a deletion that actually happened, a worse audit defect than the optimistic one this repairs.
The caller contract is untouched, per the dispatch ruling. No fork arose: the audit line can be made fully honest in the log, and
healedObjectGrantCountis documented as "what the row carries now", which stays literally true. Only its doc gained a warning that a refused resync leaves it equal to the pre-discard count.Declared deviation from the suggested route
The suggested route was to report through
reportSeedWriteRefusals. These sites reuse the shared accumulator (createSeedWriteRefusals— the shipped cross-dialect classification and the value-freecode/errnochannel, never a local regex) but not that reporter, because every sentence it prints is about seeding the RBAC catalog: "the catalog is INCOMPLETE", "this pass's seeded count", and a remedy naming the legacy platform-wide unique index on the catalog name column. Neither site seeds anything — site 1 writes two diagnostic columns onto existing rows by id, site 2 re-projects one row. Printing that text over these failures would send an operator toos migratefor a defect that is not there, which is the same "a confident wrong answer is worse than no answer" reasoning that makesotherits own class in that function rather than a relabelled unique violation. Each site emits site-appropriate prose and carries the machine facts (object, class, count, driverCodes, columns) inmeta. Pinned: the drift line asserts it does not contain "RBAC catalog".Both lines go through the shared durability helper, which is why
logSeedDurabilityFailureis now exported fromper-organization-catalog.ts(intra-package only, deliberately not added toindex.ts). Re-deriving theif (logger.error) ... else logger.warnspelling locally is what its own doc forbids — "lives in logSeedDurabilityFailure so no site can forget it". Both sites are pinned against a reduced sink with noerror.Log level
AGENTS.md "Degradation log levels", one question — after the degradation, does the system still look normal while something it claims is persisted has not landed? Yes on both:
drift_status/drift_detailare what Setup's "Needs Attention" surface reads, and the discard's caller is answered normally with a 200 by ruling while the row keeps its pre-discard grants.check:durability-log-leveldoes not vouch for either choice, and this is stated in-code at both sites. Its verdict line reads29 durability-critical catch seam(s), all loudand its summary limb names exactly 3 sites, none of them these —ql.updateis not in itsDURABILITY_CRITICAL_CALLEESvocabulary. Green here means the sites are outside the gate's reach (NOT MEASURED), never approval.Evidence
Negative control (ablation). Implementation committed first, then the three source files reverted to the pre-fix base with the tests left at HEAD:
reportDriftWriteRefusals3 to 0,resyncRefused3 to 0,export function logSeedDurabilityFailure1 to 0; on-disk hash of the drift module equal to the base blob hash;git diff --stat HEADover both test files empty);src/*.ts. The mutated run going red with no rebuild is that proof — a dist-resolved suite would have stayed green;git diff HEADempty, on-disk hash equal to the HEAD blob hash, marker count back to 3. Restore ran under atrap ... EXIT INT TERMwith absolute paths.Suites, at
fc6df0b2b(the commit every number below was measured at, and the branch head):pnpm --filter @objectstack/plugin-security test—Test Files 89 passed (89),Tests 1625 passed (1625).pnpm --filter @objectstack/plugin-security typecheck— exit 0.A NOT MEASURED result reported as such. That typecheck says nothing about the new test code: this package's
tsconfig.jsonexcludes**/*.test.ts, andtsc --noEmit --listFilesreturns 0 hits for any.test.tsfile. Measured separately through a throwaway sibling config at the package's own strictness: 11 pre-existing errors across 6 other test files, 0 in either file this PR touches. The standing gap is filed as #13176 and is not addressed here.Gates, derived from the actual changed set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(27 matched families plus the convention-triggered ones), exit codes captured before any pipe. All green:check:nul-bytes·check:logger-receiver-detach·check:engine-double-contract(OK, 705 pinned) ·check:where-matcher(314 matchers, 0 silently-wrong, baseline verified against 1a540e8) ·check:objectql-double-limit·check:durability-log-level·check:dispatcher-error-vocabulary·check:cross-package-test-inputs·check:test-source-alias·check:query-options-erasure·check:changeset-gate-self-tests·check:objectui-changeset·check:pm-half-states·check:published-files·check:page-declaration-shape·check:slot-lookup·check:type-source-resolution·check:i18n(OK, 9 packages in sync — required building the CLI first) ·check:i18n-stale-fill·check:type-check-coverage·check-adr-0087-registration·check-changeset-no-major·check-ci-filter-parity·check-comment-mask-adoption·check-empty-changeset·check-keyed-text-bounds·check-plugin-teardown-shape·check-shard-attestation·check-undeclared-dep-imports·docs-audit/check-affected-docs.Both test doubles were extended in place rather than duplicated, because this file pair is pinned 1-update-double-per-file in
scripts/engine-double-contract.pinned.json; the injected refusal throws afterassertEngineUpdateDispatchaccepts the call shape, which is the real failure order and the only onetryUpdate's catch sees.NOT MEASURED, named rather than counted as passes:
check:type-check-debt— refuses outright: 39 workspace dependencies have no built type entry point in this worktree, and its own message says a number recorded from there measures a different world. Needs the full closure build thatlint.ymlperforms before this step. CI measures it.check-test-completeness— a bare local invocation is a usage error; it takes a turbo test log CI supplies.scripts/pm/check-half-states.mjs— exit 3, PREREQUISITE NOT MET: this container has no usable GitHub credential, so nothing was swept. Unrelated to this tree, and the same measurement is why the duplicate search for@objectstack/plugin-securityexcludes**/*.test.tsfrom its only tsc program — 89 test files unchecked, 11 standing type errors behind a greentypecheck#13176 went through one targeted MCP query rather than REST.Out of scope
Untouched by ruling and confirmed untouched in the diff:
bootstrap-system-capabilities.ts(its refusal case is already counted and warned) andauto-org-admin-grant.ts(already logs on refusal). Whether the overlay-discard path should propagate to its caller instead of logging is left open by the card and deliberately not decided here.Generated by Claude Code