Skip to content

fix(rest,core): the single-kernel wiring derives the tenancy posture — an ex-member's org-stamped API key no longer reads or writes another organization's rows - #15365

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-15256-single-kernel-posture-provider
Sep 4, 2026
Merged

fix(rest,core): the single-kernel wiring derives the tenancy posture — an ex-member's org-stamped API key no longer reads or writes another organization's rows#15365
hotlong merged 5 commits into
mainfrom
claude/issue-15256-single-kernel-posture-provider

Conversation

@hotlong

@hotlong hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #15256

Implements the maintainer's 2026-09-04 ruling (1A · 2A · 3A, decision batch #33) on the seam that let an ex-member's organization-stamped API key read and write another organization's rows on the wiring the open core actually builds.

⭐ This is a takeover of a dispatch that died mid-run. The implementation was committed on the branch and its pins were left uncommitted, which is why a read of the pushed branch found them absent. Every surviving line was re-read against the ruling and re-driven here; one measured contradiction between the two halves is repaired below.

1A — the single-kernel branch derives the posture

rest-api-plugin.ts wires a tenancyServiceProvider to the lone local kernel's tenancy service, in the same shape as the authServiceProvider beside it, with objectQLProvider's classification — because decision 1 option A governs what a fault at this seam means:

  • never registered ⇒ branded ⇒ quiet undefined, no posture-conditional refusal (the supported no-tenancy composition)
  • registered and failed to build ⇒ unbranded ⇒ re-raised, so computeExecCtx answers AuthzStoreUnavailableError (503)

⛔ B′ (a boot refusal) stays withdrawn; none was added in any form.

The stale comment, and why it is paraphrased rather than quoted

rest-server.ts documented this seam as covered by a startup refusal in rest-api-plugin.ts that was withdrawn and that the plugin never carried — a p0 seam documenting itself as covered when nothing covered it.

The dead dispatch had "corrected" it by quoting the false sentence verbatim under a SUPERSEDED banner. That kept it answering every grep for the withdrawn remedy — it had already caused this seam to be re-read as unrepaired after the fix — and it contradicted the pin that forbids the phrase. Measured: 1 failed | 41 passed before the repair, the single failure being that pin.

The correction is now prose, and the forbidden phrase occurs exactly once in the repository — inside the regex that forbids it:

$ git grep -c "refused at BOOT"
packages/rest/src/execctx-authz-input-seam-reachability.test.ts:1

2A — the refusal is said out loud, server-side; the wire is unchanged

One warn per refusal, at the point each is decided — organization_required at admission, organization_membership_ended after grants — naming the sys_api_key row id, the principal, the organization and the reason. ⛔ Never the raw key, never its at-rest hash. ⛔ Not duplicated inside api-key.ts's admission path.

Real output from the run:

[security] API key refused (organization_membership_ended): key=key_exmember principal=u_exmember organization=org_alpha. The caller received the generic 401 UNAUTHENTICATED — this reason is server-side only.

The response body is unchanged, and that is asserted rather than asserted-about:

expect(JSON.stringify(res.body)).not.toMatch(/membership|organization_membership_ended|org_alpha|key_exmember/i);

ResolvedAuthzContext.authRefusal is cross-referenced only — #14273's A1 ruling removes it in its own PR. ⛔ Not removed here.

3A — the admit-on-unknown default stays; only its justification changes

api-key.ts keeps admitting an organization-less key when no posture is supplied. No behaviour change. The docblock's false premise (a "transport not yet wired" residue — in fact every deployment the open core builds) is replaced by the one legitimate case: a host that registers no tenancy service. A tenancy service that was registered and failed to build is explicitly named as not in that set.

The measurement, and the ablation

REST-level, on the single-kernel wiring under a live isolated posture, Layer 0 modelled as the hard equality tenant-layer.ts computes. Writes are read back from the store, never from the response body, and a second organization is seeded so "the wall is live" is a control rather than an assumption.

credential before after
no credential (control) 401 · 401 401 · 401 — unchanged
current member (control) 200 total 2 · 201 200 total 2 · 201 — unchanged
ex-member, key stamped org_alpha 200 total 2 · 201, row lands in org_alpha 401 · 401, nothing lands
organization-less key 200 total 0 (silent) · 403 401 · 401

Ablation — remove the provider, the leak returns

The only variable is the provider this card wired; the tenancy service, engine, keys, routes and store are byte-identical to the subject arm. A pin that cannot go red has measured nothing.

it('the ex-member WRITES into it again — POST 201, the row read back from the store carries `org_alpha` / `u_exmember`', async () => {
  const h = setup({ omitTenancyProvider: true });
  const res = await callPost(h.rest, RAW_EXMEMBER_KEY, 'w-exmember');
  expect(res.statusCode).toBe(201);
  const landed = h.store().filter((r) => r.name === 'w-exmember');
  expect(landed).toHaveLength(1);
  expect(landed[0]).toMatchObject({ organization_id: 'org_alpha', created_by: 'u_exmember' });
});

before (provider absent) — GET 200 total 2, POST 201 with the row landing organization_id: org_alpha, created_by: u_exmember, organization-less key back to 200 total 0, and no refusal line at all because no refusal was decided.
after (provider wired) — GET 401, POST 401, nothing in the store, one warn per refusal.

The member control is asserted unchanged by the ablation, so what moved is the provider and not the fixture.

⚠️ The ablation is a wiring ablation held permanently in the test, not a source mutation: setup({ omitTenancyProvider: true }) omits exactly the one argument this card added. There is no mutated tree to restore, so no restore leg can silently fail to run.

Pin (a) — every branch, not just the one that leaked

The mechanism was "one branch was never asked", so the pin is over the branch set: all three computeExecCtx wirings (scoped kernelManager, default-environment kernelManager, single-kernel providers) are driven, each required to have consulted a registered tenancy service and to have fired the refusal — with an anti-vacuity control first (a healthy member must get 200 on that same branch, so a wiring that authenticated nobody cannot "pass" by 401-ing for the wrong reason). A fourth authService assignment reds the completeness assertion.

Census — ruling item 5

Eight non-test resolveAuthzContext callers. Two supply a posture: rest-server.ts (both wirings, after this PR) and runtime/src/security/resolve-execution-context.ts (already did). The other six do not, and all six take real request headers, so all six accept x-api-key.

Not one line per site: a correct derivation must carry decision-1-option-A's classification, and a naive try { … } catch { undefined } re-introduces exactly the permissive-on-failure defect #13906 repaired. So, per the ruling, filed per site with Blocked-by: #15256#15348 (mcp stdio, an API-key-only door), #15349 (plugin-sharing), #15350 (service-datasource admin routes), #15351 (service-settings, which returns the unvetted tenantId onward), #15352 (service-storage), #15353 (cloud-connection marketplace-install). None is addressed here.

Verification

Gate union re-derived from the real diff and run on the final commit 4cfedfe01:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands   ⇒ 73 commands
73 run · 73 exit 0 · 0 red

The family grew from 45 to 73 after the docs re-anchor below entered the diff — the derivation was re-run against the actual change set rather than the first answer.

  • pnpm --filter @objectstack/rest test — 177 files / 3011 tests passed
  • pnpm --filter @objectstack/core test — 49 files / 1189 tests passed
  • typecheck both packages green; check:test-typecheck compiles the test layer separately (rest: 0 files / 0 errors in debt), so the new test files are genuinely covered rather than excluded
  • consumers of the widened core types (mcp, runtime, objectql, platform-objects) typecheck green after their closures were built. The type change is purely additive (optional keyId / userId / organizationId), so there is no narrowing to reverse-verify; the ablation is the reverse verification this card owes.
  • check:dual-build-cjs-loads and check:type-check-debt first answered exit 3, PREREQUISITE NOT MET on an unbuilt worktree — read as NOT MEASURED, not as a pass and not as a failure. Both re-ran green after turbo run build across the workspace.

content/docs/permissions/system-context.mdx is a mechanical re-anchor (check-system-context-census.mjs --fix): this card's insertions shifted the cited rest-server.ts lines by +24 before the posture block and +79 after it. Line numbers only — no prose, no row semantics.

⚠️ Verification ran in DECLARED UNLOCKED MODE. This host has no usable flock, so the shared verify lock was never taken and nothing was serialized against sibling work in the container. Every os-verify-lock.sh VERDICT line above says so.

Scope

⛔ Not in this PR, per the ruling: cloud's ARM C measurement; #14273's field removal; os serve --dev host-config paths beyond the provider. The changeset is sized as ruled — @objectstack/rest patch + @objectstack/core patch, Clause-② no — and was not re-priced upward on account of 17.3.0 having shipped with the leak. The release vehicle is the maintainer's call, in chat, and is not decided here.


Generated by Claude Code

hotlong and others added 5 commits September 4, 2026 16:28
…tate the corrected comment as prose

The dead dispatch left the 1A/2A/3A implementation committed and its pins
UNCOMMITTED, so a read of the pushed branch found them absent. This lands them
and repairs the one measured contradiction between the two halves.

`rest-server.ts` had "corrected" its stale comment by quoting the withdrawn B'
claim verbatim under a SUPERSEDED banner. That kept the false sentence answering
every grep for it — it had already caused this seam to be re-read as unrepaired
after the fix — and it directly contradicted the pin that forbids the phrase
(1 failed / 41 passed before this change). The correction is now stated as
prose, and the forbidden phrase occurs exactly once in the repository: inside
the regex that forbids it.

Also narrows that pin's sibling assertion off the words "boot refusal", which
are how both files' own no-boot-refusal notes are worded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or the single-kernel posture seam

Sized as the maintainer ruled (item 6): a fail-closed bug fix, no accept-set
change, no new public surface, Clause-2 no. Names the observable change — an
organization-less or ex-member API key on a walled single-kernel deployment now
answers 401 where it answered 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… after the rest-server insertions

Pure line rot, produced by `node scripts/check-system-context-census.mjs --fix`:
this card's additions to `rest-server.ts` shifted the cited lines by +24 before
the posture block and +79 after it. No prose and no row semantics change — only
the line numbers the page cites.

check-system-context-census: OK — 106 elevation read sites in 20 packages across
45 files, all anchored; 140 anchors resolve, 27 declared non-read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/rest, touching 22 documentable anchor(s).

30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json dc46c4ec133eb946c50805a851e4f6218058f366.

7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 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 — 30 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 dc46c4ec133eb946c50805a851e4f6218058f366packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@hotlong
hotlong added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit a727043 Sep 4, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-15256-single-kernel-posture-provider branch September 4, 2026 13:58
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/xl tests tooling

Projects

None yet

1 participant