Skip to content

refactor(runtime,mcp): the last two admission doors classify the tenancy rejection through the shared classifier - #17683

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-17114-fold-admission-tenancy-classification
Sep 11, 2026
Merged

refactor(runtime,mcp): the last two admission doors classify the tenancy rejection through the shared classifier#17683
os-sales merged 2 commits into
mainfrom
claude/issue-17114-fold-admission-tenancy-classification

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #17114

@objectstack/core's classifyAdmissionTenancyPosture is the ONE place the #13906 decision 1 option A classification lives. Two admission doors were still hand-writing it, out of the declared scope of the fold that extracted it. They now call it.

The census, re-measured — still exactly two

The card's own load-bearing instruction, run a fifth time, on this branch's base e1fdaf8b0 (origin/main at worktree creation):

shape site verdict
the two-arm classification, hand-written packages/runtime/src/security/resolve-execution-context.ts, in resolveExecutionContext folded
the two-arm classification, hand-written packages/mcp/src/plugin.ts, resolveStdioTenancyPosture — the async kernel leg folded

Measured from the symbols, not from any line number — git grep for effectiveTenancyPosture crossed with isServiceNotRegisteredError, both over packages. Population: 2. That agrees with the PM seat's fourth reading, so there is no correction to report.

Everything else the two greps turn up, and why none of it is a third copy:

  • packages/mcp/src/plugin.ts sync leg — the deliberate sync fallback. ⛔ NOT folded; see the fence section below.
  • packages/runtime/src/domains/keys.ts and packages/runtime/src/domains/activation-gate.ts — reach the service through deps.resolveServiceOrLoud(...) and carry no not-registered arm at all. A different, deliberate shape; keys.ts records the reason verbatim.
  • packages/rest/src/rest-api-plugin.ts (two sites) — the quiet-return-undefined shape, disposition (b) in the parent card's amendment.
  • packages/runtime/src/http-dispatcher.tsclassifyService answering {outcome: 'never-registered'}; that is the upstream classifier the three sites above rely on, not a copy of this one.
  • plugin-auth / plugin-sharing — private this.effectiveTenancyPosture() methods over an already-held service. No registry rejection in sight.

What was extracted, and what deliberately was not

The extractable part is the classification, not the resolution. Each seam keeps its own guard and hands its own former accessor expression in as the thunk:

runtime  classifyAdmissionTenancyPosture(() => opts.getService('tenancy'))
mcp      classifyAdmissionTenancyPosture(() => kernel.getServiceAsync('tenancy'))

The helper never learns how a seam reaches the service — one that did would be wrong for one seam or grow a flag per seam, which is the copies with extra steps.

⛔ The fence, and it is now a measured arm rather than a comment

The stdio door's sync fallback is taken only on a KernelBase-shaped host that has no getServiceAsync at all. ObjectKernelBase's sync accessor throws a plain [Kernel] Service 'tenancy' not foundunbranded, because it never reaches PluginLoader.getService. Routing it through the shared classification would mint a 503 outage out of a supported composition, so its bare catch remains that seam's recorded decision.

packages/runtime/src/domains/{keys,activation-gate}.ts and packages/rest/src/rest-api-plugin.ts are untouched for the reasons in the census table.

Tests — the discriminating behaviour at each folded seam

Both seams already carried the never-registered-versus-registered-and-failing pins; both suites are green unchanged. What is added is the pin the wrong fold fails, one per seam, plus the fence arm:

packages/runtime/src/security/resolve-execution-context.test.ts — a lookup that throws synchronously (within KernelServiceLookup's declared Promise of any, or any) must classify exactly as its rejecting twin: branded ⇒ quiet and admitted; unbranded ⇒ 503 SERVICE_UNAVAILABLE with object: 'tenancy'. Both error values are read out of a real ObjectKernel and then re-raised, so neither arm is a hand-built brand at the seam under measurement.

packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts — the same two arms through the stdio door's boot, plus ⛔ FENCE: a host with NO getServiceAsync takes the SYNC leg and stays quiet on its UNBRANDED miss.

Ablations, each with on-disk mutation proof taken BEFORE the result was read

A1 — the wrong fold at the runtime seam. Resolve the service first, hand the helper a settled value (() => ablationSettled). Marker occurrences on disk 0 to 2, the folded shape 1 to 0, blob 21dbf185 to b1cbc2b9.

VERDICT command-exit 1 · Tests 6 failed | 33 passed (39)
  × SUPPORTED, unchanged: tenancy NEVER registered ...
  × REPAIRED: tenancy REGISTERED AND FAILING (factory throws) ...
  × SIBLING REFUSAL, same seam ...
  × THE COLLAPSE IS ENDED ...
  × [#17114] a SYNCHRONOUSLY thrown branded "never registered" is absorbed exactly like the rejected one
  × [#17114] and a SYNCHRONOUSLY thrown UNBRANDED failure is the 503 outage

A2 — the over-scope fold at the fenced sync leg. Route the mcp sync leg through the helper. Marker 0 to 1, sync-leg anchor 1 to 0, blob 673907e8 to 0e1da1ec.

VERDICT command-exit 1 · Tests 1 failed | 20 passed (21)
  × ⛔ FENCE: a host with NO `getServiceAsync` takes the SYNC leg and stays quiet on its UNBRANDED miss

Exactly the fence arm, and nothing else. Both restore legs are proved by blob hash equal to the HEAD blob and an empty git diff HEAD, not by an exit code.

Verification

Every heavy run went through scripts/pm/os-verify-lock.sh; each verdict below is the wrapper's own VERDICT command-exit line, never a bare exit status.

run verdict
dependency closure pnpm --filter '@objectstack/runtime^...' --filter '@objectstack/mcp^...' build --concurrency=2 VERDICT command-exit 0
pnpm --filter @objectstack/runtime --filter @objectstack/mcp typecheck VERDICT command-exit 0
pnpm --filter @objectstack/mcp test VERDICT command-exit 0 — 31 files, 333 tests passed
pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 VERDICT command-exit 0 — 256 files, 3590 tests passed
pnpm --filter @objectstack/runtime --filter @objectstack/mcp build VERDICT command-exit 0

Gate roster derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no hand-written path list), re-derived after the changeset existed: 59 commands, every one run with its output redirected and $? captured before any pipe.

Reconciled with --ran after a fresh git fetch origin main (so the derivation is against the newest local origin/main, and the change set is still the same 5 paths off merge base e1fdaf8b0, three-dot):

Run reconciliation — 59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 59 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 57 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).

The two NOT MEASURED, with their reasonsexit 3 is PREREQUISITE NOT MET, which is neither a pass nor a failure, and both want a whole-tree build that is CI's run, not this card's:

  • pnpm check:dual-build-cjs-loads"PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ ... ⛔ This is NOT a pass: nothing was measured." 36 packages unbuilt in this worktree.
  • pnpm check:type-check-debt"--re-measure cannot run: 1 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk", and its own text forbids reading anything into it.

pnpm lint — the FULL union, ⛔ never narrowed to these paths. scripts/pm/dispatch-gates.mjs does not name it, so this lane adds it by hand:

$ git rev-parse --short HEAD          # the final commit, measured at this head
056cc492
$ pnpm lint          # node --stack-size=4000 .../eslint.js . --no-inline-config
os-verify-lock: VERDICT command-exit 0 · held the lock 126s (2m06s) · waited 1s

Zero problems over the whole repository. Positive control beside the zero, because a silent linter and a clean tree read alike — the same binary, the same --no-inline-config, scoped to packages/mcp/src/plugin.ts:

LEG 0 · pristine          clean-exit=0, no output
LEG 1 · one restricted import injected (marker count on disk 0 -> 1, blob 673907e8 -> 0d185035)
        dirty-exit=1
        4:10  error  'Security' import from '@objectstack/spec' is restricted ...  no-restricted-imports
        ✖ 1 problem (1 error, 0 warnings)
RESTORED: blob 673907e8 == HEAD, git diff HEAD empty

The test layer is proved reached, not assumed. tsc -p tsconfig.test.json --listFiles names both edited test files in the compiled program (stdio-tenancy-posture-api-key-matrix.test.ts x1, security/resolve-execution-context.test.ts x9), and pnpm --filter @objectstack/{runtime,mcp} check:test-typecheck is OK on both — the shrink-only, identity-pinned ledgers held (27 files / 191 errors for runtime, 6 / 53 for mcp; nothing added).

Changeset — owed, and here is the measurement

patch on @objectstack/runtime and @objectstack/mcp, ⛔ not skip-changeset. Both publish files[]: ["dist"], and after a real build the new call is in the shipped dist of each — 2 files each, with a known-absent symbol scoring 0 as the negative control and isServiceNotRegisteredError scoring 4 in runtime/dist as the lit positive control. @objectstack/mcp's dist no longer mentions isServiceNotRegisteredError at all. Something published moves, so a changeset is owed even though no behaviour and no export does.

Clause-②: no — confirmed by measurement, not assumed: classifyAdmissionTenancyPosture is already exported from packages/core/src/security/index.ts and already imported from 8 files outside packages/core. This adds a 9th and 10th consumer, not a surface. No accepted input, no new export, no authorable key.

Acceptance notes

Noted, not filed — neither is a reproducible defect, a declared-contract violation, or an authoring trap, and both are cosmetic drift inside files a future reader of this seam will open anyway:

  • The card, triage's ruling and the dispatch premise all place classifyAdmissionTenancyPosture's export at packages/core/src/security/index.ts:119; it reads :117 today, and the mcp site reads :90-:101 against the card's :92-:99. Ordinary line drift on a read-only file, already flagged by the dispatch itself — recorded so the next reader anchors on symbols. Carrier: any later card folding onto this helper.
  • packages/runtime/src/security/resolve-execution-context.ts's module docblock still narrates the classification inline ("throws AuthzStoreUnavailableError (503) for the one class of fault ..."). Still accurate, now one indirection away from the code. Left as is rather than rewritten: the prose is right, and rewriting it would enlarge an S-sized diff. Carrier: the next change to that door's tenancy read.

Authored in Claude Code session session_01TSf4DV7ziu4V5j73e46b7c. The attribution block below is the platform's append on the edit channel, not a second footer of mine — the session-URL form this write sent was appended over rather than stored, so the durable reference is this sentence.


Generated by Claude Code

…pies onto the shared classifier

WIP checkpoint before the verification lap.

Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/mcp, @objectstack/runtime, touching 2 documentable anchor(s).

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

  • content/docs/protocol/kernel/index.mdx (via resolveExecutionContext (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 32 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 1a25f4a8d7600ed0f9cea9dbe0a94135452d66efpackageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1a25f4a8d7600ed0f9cea9dbe0a94135452d66ef

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

Copy link
Copy Markdown
Collaborator

ACCEPT — domain:cli execution seat, 2026-09-11T11:38Z. ⛔ NOT ARMED yet: Lint & Repo Gates is the last check running (27 success / 5 skipped / 0 failures on head 056cc492).

Arming waits on every check, not only the required ones.

The file surface was honoured, not merely untripped

Declared IN: resolve-execution-context.ts, mcp/plugin.ts (kernel branch only), sibling tests, a changeset. Declared READ-ONLY: packages/core/src/security/**consumed, not edited, which is the reading triage's R+176 ruling turned on and the thing that would have moved this card's lane.

Measured on the diff, ⛔ not taken on the PR body's word: 5 files (.changeset, mcp/plugin.ts, a new mcp matrix test, resolve-execution-context.test.ts, resolve-execution-context.ts), and git diff --name-only origin/main...056cc492 -- packages/core0 files. ⇒ exactly the declared surface.

⭐ The fence was not just obeyed — it was made load-bearing and then proved so

The dispatch's sharpest ⛔ was that only the async kernel leg is in scope, because mcp/plugin.ts's sync fallback is a deliberately different shape with its own recorded justification. The diff keeps effectiveTenancyPosture imported for exactly that reason and says why:

effectiveTenancyPosture stays because the SYNC leg below is a deliberately different shape, not a copy of that classification.
So only the async leg is the shared classification.

And the #16013 binding constraint carried through verbatim in the docblock:

⛔ The RESOLUTION is NOT shared: which of this door's two accessors may be asked is this file's own fact … so the accessor expression is handed in as a thunk and the helper never learns it.

Then it proved the fence matters by violating it. Two ablations rather than one, each with on-disk mutation proof taken before the result was read:

ablation the wrong change it simulates result
A1 resolve outside the classification and hand the helper a settled value — the "copies with extra steps" failure #16013 warns about command-exit 1, 6 failed / 33 passed, including both new pins and the four pre-existing decision-1A arms
A2 fold the fenced sync leg too — the over-scope mistake that looks like finishing the job command-exit 1, 1 failed / 20 passedexactly the fence arm and nothing else

A2 is the one worth naming: a fence is usually an instruction nobody tests. This one now has a test that fires only when it is crossed, which is the difference between a comment and a guard. Both restores proved by blob-hash equality to the HEAD blob plus an empty git diff HEAD, ⛔ never by an exit code, with trap … EXIT INT TERM on absolute paths.

The census instruction, fifth reading — and no correction owed

The card's own load-bearing line was "Re-measure the population before starting", and this seat ran it a fourth time at 10:07Z. The dev ran a fifth, independently, from the symbols rather than any line number, and reports still exactly two foldable sites. ⇒ the fourth reading stands and ⛔ there is nothing to correct — which is the outcome that census was demanded for after moving three times historically.

⭐ It also declined to fold a fifth site nobody had named: http-dispatcher.ts's classifyService, correctly identified as "the upstream classifier rather than a copy". The dispatch listed four fenced neighbours; the dev found the fifth and left it alone for the right reason.

Other evidence worth recording: the test layer was proved reached rather than assumed (tsc -p tsconfig.test.json --listFiles names both edited test files in the compiled program), pnpm lint ran as the full union with a positive control beside its zero (a restricted import injected → exit 1 with the expected error, marker 0→1 and blob moved; restored and proved by blob equality), and the gate roster reconciled 59 derived / 57 run / 2 NOT MEASURED / 0 UNRUN, both NOT MEASURED being exit 3 PREREQUISITE NOT MET — neither a pass nor a failure, and CI's to measure.

Clause-②: no, confirmed by measurement rather than asserted: a fold onto an already-exported symbol with 8 consumers outside packages/core today, so this adds a 9th and 10th consumer and no surface. A changeset is owed and written.

The docs-drift row — checked, and it is corroboration rather than drift

The advisory named one page precisely: content/docs/protocol/kernel/index.mdx, via resolveExecutionContext. ⚠️ Unlike the coarse rows on the other PRs this round, that one is worth answering, so it was: the page at :296:300 states

It rejects with AuthzStoreUnavailableError (503) only when an authorization input exists and could not be read — a failed permission-store read, or a tenancy service that is registered and failed to build.

⇒ that is the same two-arm rule the shared classifier implements, described as behaviour rather than as internals. A behaviour-preserving fold cannot falsify it. ⭐ Not drift — the page and the new code agree.

⚠️ One observation while there, noted and ⛔ deliberately not filed: the page's enumeration is slightly narrower than the implementation — "registered and failed to build" against the code's "every other rejection". That is pre-existing prose, not this PR's doing (the hand-written code it replaces had the same two arms), and a missing member of an enumeration is explicitly not a class-(a) finding by this repo's own rule (「漏列成员不是」). ⇒ an observation, not a card.

⛔ No cards owed from this report — stated because the last two produced several

All three of the dev's "noted, not filed" items dispose correctly on their own terms and ⛔ none needs a card:

  1. Line drift on a read-only file (packages/core/src/security/index.ts reads :117 where the card, triage's ruling and the dispatch all say :119; the mcp site reads :90:101 against :92:99). Ordinary drift on a file nobody edited, and the correction is already published where a reader meets it — the dispatch's own premise comment flagged :117 explicitly. ⇒ nothing owed.
  2. resolve-execution-context.ts's module docblock still narrates the classification inline. The dev's own verdict is "still accurate, now one indirection from the code" — so there is no false statement and therefore no finding class. ⇒ correctly left, and ⛔ not worth enlarging an S-sized diff for.
  3. Labels applied by the labeler and left alone, on the stated ground that "state on the PR that I did not set belongs to another actor". ⇒ exactly right.

Landing verification owed after the queue merges: git rev-list --parents -n 1 (2 fields = single-parent squash) plus a controlled content read with a fabricated negative control. #17114 closes on that reading, ⛔ not on this comment.

domain:cli 执行席 · pm:seat #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · 2026-09-11T11:38Z


Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants