Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-sharing): run the ADR-0111 D7 inert-grant guard for SYSTEM callers - #8311
Conversation
… callers (#8207) `SharingService.grant` skipped both of its pre-flights for a system context in one block. The two halves ask different questions and only one may vary by caller: D1 (`assertCanManageShares`) is an AUTHORIZATION check, which a rule rightly skips because a rule is not a principal; D7 is an INERTNESS check — "would any gate ever read a row on this object?" — whose answer does not depend on who asks, because the gates that would consult the row never see the granter. Measured on origin/main @ a7e94e9: the rule evaluator does NOT independently reject the inert object classes. One rule per class, defineRule then evaluateRule under a system context, materialised a real sys_record_share row for all five — public model, owner-less, controlled_by_parent, federated phantom anchor (#8119), bypass object. The inertness verdict is now computed caller-free (`inertGrantReason`) and refused for every caller (`assertNotInertGrant`). The EXISTENCE check stays non-system-only: an unresolvable object name is a caller's mistake (NOT_FOUND), but for the evaluator it is a stored object_name against an engine that may not have that schema registered yet, and absence of a schema is absence of evidence of inertness rather than evidence of it. `POST /sharing/rules/:idOrName/evaluate` maps the refusal to 422 SHARING_NOT_ENABLED — the same code/status pair the per-record shares routes already publish — instead of burying the diagnosis in a 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
…nvelope (#8207) The 422 arm added by the previous commit copied its three neighbours in `registerSharingRuleEndpoints` — the flat `{ code, error: '<string>' }` dialect, `code` beside `error` instead of inside it, so `body.error.code` reads `undefined`. Those three are #7035's declared debt, held down by the `check:route-envelope` ratchet's `siblingCode` count, which only ticks DOWN. Measured on this branch: with the flat arm the gate reports `siblingCode: found 70, declared 69 — a NEW non-conforming body`; through the shared `sendError` (`sendEnvelopeError`, aliased at rest-server.ts:89) it is green at 69. A new arm copying its neighbours' shape is exactly what that ratchet exists to stop, and raising the declared number is a maintainer action, not this card's. The test file now pins the NESTED pair for the new arm (`body.error.code` + `body.error.message`, `success: false`, no top-level `code`), and keeps reading the sibling arms through the position-tolerant helper — they are asserted for their STATUS only, which is all this card claims about them, so converting them later under #8111's unfinished half does not go red here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
…tem-caller-inert-grant-guard
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
…tem-caller-inert-grant-guard
… dispatch contract `check:engine-double-contract` reported the new test file's engine double as `PINNED [update]: … declares 1 engine double(s) whose update() does not route through assertEngineUpdateDispatch (line 88)`. The double was loose in two ways, not one. It mirrored the DRIVER arity (`update(object, id, data)`) alongside the engine one — a shape `IDataEngine` does not have at all — and, on the engine arm, it dispatched on a hand-derived `data.id` with no rejection surface, so it accepted the predicate updates `ObjectQL.update` refuses. Both are the #4434 vacuity class the gate exists for: a double that accepts what the producer rejects is a test that cannot fail. `update` now opens with `assertEngineUpdateDispatch(data, options)` from `@objectstack/metadata-core` — already a dependency of this package and already the source of the `assertEngineDeleteDispatch` on the `delete` member two lines below — and implements the returned `by-id` / `multi` verdicts separately. `metadata-core` deliberately, not `@objectstack/objectql`: objectql depends on this package's siblings and the reverse edge is a cycle turbo refuses. No baseline entry added — `scripts/engine-double-contract.baseline.json` is shrink-only and raising it is a maintainer action. Gate now reads `OK — 186 pinned, 133 in the DEBT ledger, 2 exempt`; suite still 24/24. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8207
SharingService.grantskipped both of its pre-flights for a system context in oneblock. The two halves ask different questions and only one may vary by caller: D1
(
assertCanManageShares) is an AUTHORIZATION check, which a rule rightly skips because arule is not a principal; D7 is an INERTNESS check — "would any gate ever read a row on this
object?" — whose answer cannot depend on who asks, because the gates that would consult the
row never see the granter.
The inertness verdict is now computed caller-free (
inertGrantReason) and refused for everycaller (
assertNotInertGrant). The D1 management gate stays system-skipped. The EXISTENCEcheck also stays non-system-only: an unresolvable object name is a caller's mistake
(NOT_FOUND), but for the evaluator it is a stored
object_nameagainst an engine that maynot have that schema registered yet, and absence of a schema is absence of evidence of
inertness rather than evidence of it.
Ablation — prediction written before the mutation, then run
Both ablations were predicted in full (per test, not per total) and the prediction file was
written to disk before the production code was touched. An assertion whose expectation and
reality derive from the same source cannot fail; these can.
A — revert
sharing-service.tstoorigin/main, keep the testsBaseline 24/24 green. Predicted 17 FAIL / 7 PASS; actual 17 FAIL / 7 PASS, and the
failing set matched test-for-test.
refuses a SYSTEM grant on %s(whiteboard, note, detail_item, ext_nostamp, sys_user)the SYSTEM refusal on %s is the SAME verdict the user path gives(x5)an engine with NO schema access still cannot answer, for either callerevaluating a rule on %s mints NOTHING(x5)the boot backfill still COMPLETESreconciled6 not 1expected 6 to be 1the code is one the package DECLARES in the ADR-0112 ledgerANTI-VACUITY: a SYSTEM grant on an ordinary private object still succeedsANTI-VACUITY: the row that system grant wrote is LIVEthe D1 MANAGEMENT gate stays system-skippedan UNRESOLVABLE object stays a user-only NOT_FOUNDANTI-VACUITY: the same rule shape on an ordinary object still materialisesWITHDRAWAL still works on an inert objectThe non-obvious one is
an engine with NO schema access— it fails not on its firstassertion but its second, because on
mainthe whole-block system skip also bypasses thebypass-object check, which needs no schema at all. Predicted as such, observed as such.
B — delete the
SHARING_NOT_ENABLED422 arm fromrest-server.tsBaseline 5/5 green. Predicted 2 FAIL / 3 PASS; actual 2 FAIL / 3 PASS.
answers 422 SHARING_NOT_ENABLEDexpected 500 to be 422, body{"code":"RULE_EVALUATE_FAILED", ...}the message names the object and survives, minus the internal prefixstartsWithflips true); the 1st still passes because the 500 body carries the whole messageexpected true to be false— the 2nd assertion, as predictedthe code is one the platform DECLARESANTI-VACUITY: a successful evaluation still answers 200ANTI-VACUITY: the sibling arms still map to their own statusesBoot rule backfill — measured on a REAL engine, not a fake
The issue names this as the most likely way the change breaks something real. The committed
unit test covers it against a fake engine; that is not a boot. This was re-measured on a real
ObjectQLengine, a realSqlDriveron sqlite:memory:, real DDL throughsyncSchemas(),and the real
SysRecordShare/SysSharingRuleplatform object definitions, driving thereal
backfillRuleGrantswith two real rules — one ordinary, one on an inert object:The backfill completes. The inert rule is refused with a reason naming the object, the
operator gets it by rule name, and the sibling ordinary rule still materialises its grant.
That harness was a scratch file, run and deleted; it is not part of this diff.
Worth recording: no shipped example app declares a sharing rule at all, so booting
app-showcasecannot reach this log line (backfillRuleGrantsonly logs whenrules.length > 0). That matches the issue's own "dormant as far as I measured" framing, andis why the measurement above builds the rules explicitly.
The
check:route-enveloperatchet claim in1018d75fb— verified, not assumedThe commit claims the sibling arms are declared debt held by a down-only ratchet. All three
halves measured:
scripts/check-route-envelope.mjs, wired ascheck:route-envelope,declaring
siblingCode: 69forrest-server.ts.res.status(422).json({ code, error })reproduces the commit's quoted line verbatim:siblingCode: found 70, declared 69 — a NEW non-conforming body.RULE_NOT_FOUND) ontothe shared envelope drops the count:
siblingCode: found 68, declared 69 — 1 fewer than pinned.So those arms really are inside the 69.One correction to the framing, in the PR rather than the code: the gate is an exact-equality
check, so it reddens in both directions. "Ticks down only" is the policy for lowering the
declared number, not the gate's behaviour — which is precisely why converting the siblings here
was not an option: banking that progress requires editing the declared number, a maintainer
action. The comment's substance is correct and stands.
Merits judgement on the inherited commits
Both hold up. Specific things checked rather than assumed:
assertSharingEnforcedrefactor is behaviour-preserving on the non-system path. Oldorder: bypass throws first, then
getSchemamissing returns, then!schemais NOT_FOUND.New order: the NOT_FOUND probe is guarded by
!bypassObjects.has(object), so a bypassobject still yields SHARING_NOT_ENABLED rather than NOT_FOUND. Same verdicts, same
precedence. It has exactly one caller (the non-system
grantpath), so nothing else moved.backfillRuleGrantswraps eachevaluateRulein try/catch, warns, and continues, and theboot rule backfill doneline is outside that loop.Two consequences worth stating, neither a defect:
getSchemais now called twice (once for existence, once insideinertGrantReason). Trivial cost, and the alternative re-entangles the two verdicts.reconcileat the firstgrant, so that rule'srevoke-stale phase no longer runs. Rows an older build minted on such an object therefore
stay until
deleteRuleor a deactivated-rule evaluation purges them — both untouched andboth pinned by the
WITHDRAWALtest. No access consequence: those rows are inert bydefinition.
Gate fix folded in
check:engine-double-contractwas red on the inherited test file (reported under theESLint job, which runs a battery). Its fake engine's
updatewas loose in two ways: itmirrored the DRIVER arity
update(object, id, data), whichIDataEnginedoes not have, andits engine arm dispatched on a hand-derived
data.idwith no rejection surface. It now openswith
assertEngineUpdateDispatch(data, options)from@objectstack/metadata-core— already adependency here, and already the source of the
assertEngineDeleteDispatchtwo lines below.No baseline entry was added; that file is shrink-only and raising it is a maintainer action.
Probed while in there, per the gate's own dormancy methodology: this suite never calls that
updatemember (a stderr marker printed 0 times, while the same marker oninsertprinted 20times in the same run). So the looseness was unexercised — the fix is correct and required, but
it hardens a currently-dormant path rather than repairing a live false green.
Verification run
plugin-sharingfull suiterestfull suitetypecheck(plugin-sharing + rest)check:engine-double-contractcheck:route-envelopecheck:nul-bytescheck:error-code-casingNot run locally, left to CI: the rest of the lint-workflow gate farm, and the full-workspace
build/test. One pre-existing
eslintfinding atrest-server.ts:4595(
ban-ts-commentrule not found) reproduces unchanged onorigin/mainand is an artifact ofinvoking
npx eslinton single files outside the repo's lint entrypoint — not from this diff.Generated by Claude Code
Generated by Claude Code