Skip to content

test(rest): pin the PARTIAL permission-store outage at the /meta doors with a per-read fault injector - #13614

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13538-partial-outage-fault-injector
Aug 31, 2026
Merged

test(rest): pin the PARTIAL permission-store outage at the /meta doors with a per-read fault injector#13614
os-steve merged 3 commits into
mainfrom
claude/issue-13538-partial-outage-fault-injector

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13538

Adds §9 to packages/rest/src/execctx-consumer-census.test.ts: a per-read fault injector that drives the /meta doors under a PARTIAL permission-store outage — only the chosen read fails — and pins that the answer is not an org-unscoped 200.

packages/rest/src/rest-server.ts is not in this diff. One file changed, +210 lines, test-only.

The mandatory question: which existing pin could have caught this, and did not

§8. Not §7.

§7 re-derives the catch ARGUMENT at every site from source text. A regression that changes no spelling is invisible to it by construction, so it could never have caught this — and a stricter spelling assertion is the explicit non-fix the card rules out.

§8 is the one that could have. It is already behavioural, already drives these doors, already mounts them ISOLATED, and already records which sites each route touched — every part of the instrument except the fault MODEL. So what is owed is a changed criterion on §8's harness, not a new source-text case and not a new file. §8 keeps its own subject: a total outage is a real shape and still must not be served as success.

Two things in that model kept it away from the partial case, and §9 changes both:

  1. the fault is unconditional, so "the first read fails and the next succeeds" cannot be expressed at all; and
  2. it drives type: 'object', and declaresOrgOverride('object') is false — that type is env-wide BY DESIGN, healthy or faulted. The difference this card is about does not exist on the type §8 sweeps, whatever fault model it were given.

§9 therefore drives an org-overridable type and asserts on the request the handler built, not on the status code. An org-unscoped read is the harm; a status is downstream of it. That is what survives a refactor which changes no spelling.

Measured correction: why §8 really stays green

The card, and §8's own note, say the :type handler resolves the context three times, so a later guarded resolve saves the door. Re-derived by symbol, that is not the operative mechanism. The handler's other two resolves sit behind metaTypeSingular(...) === 'app' and === 'dashboard' and never run for object at all.

Ablation recorded the list door directly:

legstatussites reachedread carried
TOTAL outage5032821, 4388(door refused)
PARTIAL, first read2004388no organizationId

Site 2821 is resolveObjectMasker's guarded resolve, and that method early-returns unless metaType === 'object'. So what actually keeps §8 green under a swallow is a second guarded resolve that only exists for object — the same fixture choice that makes its org-scope question vacuous. Two independent blind spots, both flowing from one fixture.

The card's conclusion was right; the mechanism it named was not. §8's paragraph is left intact and a correction is added beside it, so the historical record stays readable. This is the bounded in-place fix in this PR beyond the new section, and it is named here rather than left as a silent tidy-up.

Ablation — reach of the new pin, with predictions declared first

Every leg mutated rest-server.tsin this worktree only, under a trap, and restored; nothing from those legs is in the diff. Restoration was proved on both legs by git hash-object equalling the HEAD blob AND git diff HEAD empty (an empty hash treated as FAILURE, not "nothing to compare"). Mutation was proved ON DISK, never from an editor exit code: the catch(() => undefined) occurrence count moved 16 -> 17, and the collapse leg's removed and injected texts were counted separately (0 and 1).

No rebuild is required for these legs, and that is a property of the suite rather than an assumption: the census imports RestServer through the relative specifier ./rest-server.js, which vitest resolves to the TypeScript source beside it — never through a package exports map to dist/. There is positive proof inside the harness: the site recorder reads rest-server.ts:LINE:COL off the stack, and the controls assert the site lists are non-empty, which is impossible if the executing module were a built artifact.

leg (all restored)predictedmeasured
A — swallow at the :type first read§7 red, §9 red, §8red§7 red, §9 red, §8GREEN — prediction falsified, card confirmed
B — collapse, first read becomes the only one (no spelling change)all green§9 GREEN; §2 and §7 CONTROL red on counting drift only
C — collapse and swallow§9 red, §8 green§9RED (both starred), §8GREEN, §7 red

Leg C is the one that matters. It is the configuration where §8's accidental protection is gone, and there §9 is the only thing that reddens on the behaviour: §7's signal is a spelling-and-count drift that a refactorer would "fix" by updating numbers, after which nothing would remain.

Leg B is a falsification worth recording: a collapse on its own reintroduces no harm, because every site still re-raises — so §9 staying green there is correct, not a gap. What the collapse actually removes is §8's protection, which is why the decisive leg had to combine the two.

Verification

Union run at 83a2f0900, the final commit, after merging origin/main.

  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/execctx-consumer-census.test.ts — exit 0, Tests 28 passed (28) (20 to 25 it blocks; the 5 added are §9).
  • pnpm --filter @objectstack/rest run typecheck — exit 0. Note this is not the "typecheck excludes test files" blind spot: the package runs check:test-typecheck, which compiles the test layer under tsconfig.test.json and says so.
  • pnpm lint (repo-wide eslint . --no-inline-config, the derivation's known blind spot) — exit 0, run in full. No narrowing claimed.
  • Gate union re-derived AFTER the merge with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, stderr read: no stale-tree banner, --repo assertion holds. 26 families.

23 PASS · 3 NOT MEASURED · 0 FAIL. Exit codes captured before any pipe. The three are refusals that say so in their own words, and are NOT folded into the pass list:

  • pnpm check:type-check-debt — "--re-measure cannot run: 26 workspace dependencies have no built type entry point on disk". Needs the closure built.
  • pnpm check:dual-build-cjs-loads — "PREREQUISITE NOT MET ... this is NOT a pass: nothing was measured". Its own self-test passed (93 cases).
  • node scripts/check-test-completeness.mjs — exits 3 and states "the local reading for this gate is NOT MEASURED ... it is not a red"; it needs a saved test-run log CI tees in.

Changeset

skip-changeset, label applied on open. The diff is a single test file: it publishes nothing from any package and changes no user-visible behaviour. Stated here rather than assumed.

Scope

/meta doors only. No assertion in §9 mentions /ui/view, so it stays disjoint from the concurrent work on registerUiEndpoints in rest-server.ts. The 21 sites converted by PR #13475 are not reopened.


Generated by Claude Code

…s with a per-read fault injector
[#13538] Adds census section 9. Sections 7 and 8 leave a gap between them: 7
re-derives the catch ARGUMENT at every resolveExecCtx site from source text, so
a regression that changes no spelling is invisible to it by construction; 8 is
behavioural but drives an UNCONDITIONAL rejection, i.e. a TOTAL outage.
The case in between is the harmful one. When only the FIRST read fails, a
swallowing site returns undefined, the handler proceeds with no tenant, and
organizationIdForMetaRead is handed no organization -- so the door issues an
env-wide read and answers an org-unscoped 200 carrying rows from outside the
caller's organization. It is not a refusal, so nothing downstream notices.
Section 9 changes section 8's CRITERION rather than adding a stricter
source-text pin (the explicit non-fix). Two changes to the fault model, both
measured in controls rather than assumed:
- fault SELECTION per read (`failOrdinal`, 1-based, reset per driven
request), so "the first read fails and the next succeeds" is expressible at
all; and
- an ORG-OVERRIDABLE driving type. Section 8 sweeps `object`, for which
declaresOrgOverride is FALSE -- that type is env-wide BY DESIGN, healthy or
faulted, so no fault model could make section 8 observe an org-scope
difference on the type it drives. A control pins both halves.
The load-bearing assertion is on the REQUEST the handler built, not on the
status code: an org-unscoped read is the harm and a status is downstream of it.
That is what survives a refactor which changes no spelling.
Section 8 keeps its own subject -- a total outage is a real shape and still
must not be served as success. rest-server.ts is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
… and correct the reading beside it
[#13538] Ablation (collapse + swallow, both restored, rest-server.ts unchanged
in the diff) recorded the list door directly:
TOTAL outage -> status 503, sites [2821, 4388]
PARTIAL (1st) -> status 200, sites [4388], read carried NO organizationId
Site 2821 is resolveObjectMasker's guarded resolve, and that method early-returns
unless metaType === 'object'. So what keeps section 8 green under a swallow is a
second guarded resolve that only exists for `object` -- the same fixture choice
that makes its org-scope question vacuous. It is NOT the three-resolves-in-the-
handler reading recorded in the card and in section 8's own note: the handler's
other two resolves sit behind metaTypeSingular(...) === 'app' and === 'dashboard'
and never run for `object` at all.
The conclusion those notes drew was right; the mechanism they named was not.
Section 8's paragraph is left intact and a correction is added beside it, so the
historical record stays readable. Re-deriving by SYMBOL rather than by line is
what separated the two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A PARTIAL permission-store outage can still fabricate an org-unscoped 200 on the meta doors, and only a source-text pin stands against it

2 participants

@os-steve@claude