Skip to content

feat(objectql,core): one tenant read predicate for Layer 0 and every driver (ADR-0131 C8 stage 8a) - #24389

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-15212-8a-one-predicate
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-15212-8a-one-predicate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Part of #15212
Clause-②: yes

ADR-0131 C8, stage 8a — one tenant read predicate (D8), per the stage plan 6251466182 and the claim 6251491183. #15831 (the driver leg ignored platformGlobalObjects) is closed by pointer after this merges; its tripwire pin already reads 2 (measured below).

What changed

  • One function decides. computeTenantLayer0Verdict and TenantLayer0Input move to packages/core/src/security/tenant-layer0-verdict.ts (new @objectstack/core export). plugin-security's tenant-layer.ts re-exports and calls it; its Layer 0 filter, the recorded verdict and the intersect are byte-identical.
  • The engine projects that verdict onto the driver. buildDriverOptions (packages/objectql/src/engine.ts) no longer derives DriverOptions.tenantId / tenantIds on its own. For a read, count, aggregate, update or delete it projects:
    • the verdict the wall RECORDED on this operation (OperationContext.tenantLayer0Verdict, parsed with TenantLayer0VerdictSchema), where the security layer composed its wall. That verdict carries the exempt PLATFORM_ADMIN, the verified owner's read lift and the delegator intersection;
    • otherwise the same function over the engine's own facts: the posture in force (resolveEnginePosture), the context's organization and membership set, no admin exemption. This covers a system context, a by-id write, and a composition with no security layer.
    • Projection: none → no scope; organization → tenantId; organizations → tenantIds (with the active organization as tenantId, which the driver keys on); deny → the active organization if any (the narrowest scope a driver can express).
  • The write target is untouched. The insert path passes writeTarget: true and keeps the active organization as tenantId (and the group membership set) in every posture, so the row stamp and the counter scope hold. The engine has no upsert door (ENGINE_UPDATE_UPSERT_REMOVED); the SQL driver's own upsert cross-organization conflict refusal is reached only by direct driver callers and is untouched by this change. (Wording corrected by the epic seat from contract review 6254710664 ①(d).)
  • Not touched: both orWhereNull arms in sql-driver.ts (stage 8c-1), sharing-rule-service.ts, the ownership ceremony, and the 8b-0a / 8b-0b producers.
  • Docblocks that described the double derivation are corrected (buildDriverOptions, memory-tenancy-guard.ts). ADR-0087 semantic entry driver-read-scope-follows-tenant-wall; changeset 15212-8a-one-predicate.md.

Clause-② — what widens

posture / caller driver scope before driver scope now
single, a caller naming an organization that organization (plus organization-less rows) none
walled, exempt PLATFORM_ADMIN on a posture-permitting object, or the verified owner's read the active organization none (what Layer 0 already admits)
isolated / group member; walled system context naming an organization equality / union unchanged

Consequence, settled by ADR-0131 D8/D11: a single install holding more than one organization (the org_bu dogfood fixture shape) loses the driver isolation it had by accident. single enforces no wall. Organizations are kept apart on isolated / group.

Mechanism assumptions (Section 2), measured

  • A1 — holds. buildDriverOptions derived tenantId from execCtx.tenantId alone (and tenantIds only via the provider's group). Implemented as above.

  • A2 — holds. @objectstack/objectql and @objectstack/plugin-security both already depend on @objectstack/core, and core depends on neither. check-undeclared-dep-imports is green.

  • A3 — already true before this PR. tenant-layer0-verdict-end-to-end.test.ts:181 reads toBe(2) on main. ADR-0131 D7 (C6) made the carve-out total: a declared object is planned with no organization column, so the driver has nothing to scope. This PR leaves that pin as it is.

  • A4 — holds, with the table above.

  • A5 — measured. 8-M's heuristic counted up to 38 suites. The suites that actually failed were 13 files across 6 packages:

    • objectql: engine.test.ts, engine-external-tenant-scope, engine-reference-tenant-scope, engine-findone-contract, engine-aggregate-search;
    • plugin-security: explain-enforce-parity;
    • plugin-sharing: rule-criteria-org-scope, record-share-organization-stamp;
    • service-storage: chunk-part-record-concurrency;
    • dogfood: schedule-sweep-organization-scope, schedule-acting-organization, invitation-ledger-row-scope.

    Each pin that asserted cross-organization isolation now runs under an explicit isolated posture. The single cases assert the widened answer. In explain-enforce-parity, the NATIVE_SCOPING_UNDER_SINGLE "measured divergence" joins the invariant: explain and enforcement now agree under single.

  • A6 — holds. 1,008 changed lines in total (763 added, 245 deleted, 20 files): 609 in source, changeset and entry, and 399 in tests.

Landing points outside the claim's named list. These are the default-posture suites the claim's last bullet covers, measured above: service-storage/src/chunk-part-record-concurrency.test.ts, plugin-sharing/src/record-share-organization-stamp.test.ts, and the three dogfood suites. No source file outside the claim surface was edited.

Verification (on cc6801a0e, main merged)

  • Package suites:
    • objectql: 444 files, 8,337 passed.
    • plugin-security: 187 files, 3,736 passed, 45 skipped.
    • plugin-sharing: 1,117 passed. service-storage: 1,041 passed. core: 2,634 passed.
  • Dogfood: all 8 shards, 2,266 passed, 18 skipped, 0 failed.
  • Before the merge (same diff): driver-sql 3,677 passed; runtime (4 shards) 7,414 passed; plugin-audit, plugin-auth, plugin-approvals, plugin-email, organizations, driver-memory, service-messaging, service-settings, service-analytics, service-mailbox and service-automation all green.
    • @objectstack/rest: only the ObjectQL-with-tenant suite was run (52 passed), because the full suite is longer than the foreground cap. CI runs the rest.
  • Typecheck green: core, objectql, plugin-security, plugin-sharing, service-storage, driver-memory, dogfood, spec.
  • Ablation (scripts/ablation-replace.mjs, test reads src/): re-adding the active-organization scope on a none verdict reddens exactly the two widening pins in engine-driver-read-scope.test.ts (2 failed, 11 passed). The restore was verified: blob equals HEAD and git diff HEAD is empty.
  • Gates: dispatch-gates --commands derived 100 families, and --ran reconciled 100 run with 0 NOT-MEASURED. pnpm --filter @objectstack/spec check:generated is green, and check:adr-0087-registration reads 1 non-breaking changeset.

Acceptance notes

  • On a single install, an org-bound session's reads no longer reach the memory driver with a tenant scope. The guard's seam 3 judges whatever scope arrives. Writes still carry the target, so the memory driver still refuses them.
  • The @objectstack/verify harness comment is now stale. It says the audience-gate invitations under org_verify_audience_gate are invisible to fixtures (harness.ts, inviteForAudienceGate). On single they are now visible to the owner, and invitation-ledger-row-scope filters them. The comment was left as it is because it is outside this stage's surface.
  • The schedule dogfood suites set the engine's posture provider to isolated around the bind. They model a walled deployment without booting the enterprise organizations plugin, and the driver scope now follows the engine's posture rather than the context's organization alone.

Generated by Claude Code

…ult-posture isolation pins move to an explicit wall
…tion under the default posture move to an explicit wall; the single-posture explain divergence joins the invariant
…icate engine; ADR-0087 semantic entry and changeset for the widened driver read
…alled posture they model; control B's memory probe is the org-bound write
…dger, not the harness's audience-gate rows a single install now shows
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/core, @objectstack/driver-memory, @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/memory-tenancy-guard.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

26 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 b2e78701f779809b588096d82f5d86ba4ab5cfde.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/memory-tenancy-guard.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 151 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 b2e78701f779809b588096d82f5d86ba4ab5cfde → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cc6801a0e207fb22d960c8493304f28cb56b5568
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts + .changeset/15212-8a-one-predicate.md, packages/core/src/security/tenant-layer0-verdict.ts, packages/core/src/security/index.ts, packages/objectql/src/engine.ts, packages/plugins/plugin-security/src/tenant-layer.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/drivers/driver-memory/src/memory-tenancy-guard.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/spec/src/security/tenant-layer0-verdict.ts, packages/spec/src/security/tenancy-posture.ts, packages/spec/src/data/object.zod.ts, packages/types/src/env.ts, packages/spec/src/migrations/types.ts, packages/spec/src/migrations/registry.ts.template, packages/spec/scripts/build-migration-registry.ts, packages/spec/scripts/build-upgrade-guide.ts, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts, docs/adr/0131-total-organization-ownership-no-null-organization-id.md (D8, D9, D11, D14), and the 13 test files of the diff.

Rendered by an isolated at-tier reviewer subagent of the adopting seat (session_01Y5583bs2RDd53MH6kM1ca3), read-only: the net diff origin/main...refs/review/pr-24389 (20 files, +763/−245), the PR body and its one comment, card #15212's body and all 17 comments (stage plan 6251466182 and its four amendments, claim 6251491183, os-dev-report 6254147295), #15831's body, ADR-0131 on origin/main 5e979073, and the 41 check-runs on the head. The head was cc6801a0e… at review time, as dispatched. Check-runs: the five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) and every other run success; six full-battery jobs skipped as on every PR run. No governed-surface path in the file list; 1,008 changed lines; head repo = base repo; the PR is draft.

① Derived judgments

(a) Can the recorded verdict be forged? — No caller-reachable path. Right. OperationContext.tenantLayer0Verdict (engine.ts:3390-3422) lives on the engine's middleware context, not on the caller's bag. Every construction site builds the literal with named keys and stores the caller's query/options under options, never spread: find :14138, findOne :14437, insert :15263, update :16487, delete :19482, count :20014, aggregate :20428. So a REST body or header, an SDK options, an AI tool call (all of which enter through those seven doors) cannot place the member. Hooks receive HookContext (:14150-14160: object, event, input, session, provenance, user, api, transaction, ql) — no reference to the operation context. ExecutionContext carries no such member, and no withSystemContext-style helper exists in core or objectql (grep: none). Repo-wide, the only non-test writer is plugin-security's middleware (security-plugin.ts:4227, after the delegator intersection at :4198); the two test plants (engine-data-events.test.ts:828, engine-driver-read-scope.test.ts:75) go through engine.registerMiddleware. That is the one path: a middleware registered on the engine (engine.ts:6516, run in registration order at :6562-6566 on the same ctx) can overwrite the member — but such a middleware can equally rewrite ctx.ast.where and strip the wall itself, so the forge confers no power a plugin did not already hold; it is composition-level trust, the trust #15813 already placed in this member for the bulk-event producer. The reader parses with the .strict() TenantLayer0VerdictSchema (spec tenant-layer0-verdict.ts:65-86) and a value that does not parse reads as absent → the engine-side fallback with no admin exemption (engine.ts:6971-6986; pinned at engine-driver-read-scope.test.ts:462-466). Two pre-existing, bounded paths worth naming, both unchanged by this diff: a beforeFind hook may set input.options.tenantId/tenantIds, which buildDriverOptions keeps (:6908-6914 fill only undefined) — it can add a scope or widen the driver's IN set, but Layer 0's where (composed onto the AST at security-plugin.ts:4176-4178 before hooks) still bounds the rows; and the fallback's inputs (context.tenantId, accessible_org_ids, isSystem) are session-derived by REST, not body-derived. Neither widens past Layer 0.

(b) Under a wall, a wider driver scope for a caller who is neither the exempt PLATFORM_ADMIN nor the verified owner? — None found, with one env-only exception that is in the declared direction (see ②). Traced on driverReadScope (engine.ts:6963-6999) against the pre-PR derivation (old :6832-6838, execCtx.tenantId always; tenantIds only when tenancyPostureProvider?.() === 'group'):

  • recorded organization — Layer 0 names context.tenantId (security-plugin.ts:7447-7449), so the id equals the old active-organization equality; a delegator intersection can only narrow (deny), never re-point;
  • recorded organizations — the set is the caller's membership or the delegator intersection (a subset); the SQL driver reads tenantIds as IN (…) OR NULL and does not AND the tenantId equality under it (sql-driver.ts:15578-15609), so the engine's tenantId: active ?? ids[0] is only the key the driver tests for presence — right;
  • recorded deny — a group member with an empty set keeps the active organization (same as before), and the where already carries RLS_DENY_FILTER; isolated with no active organization → no scope, as before (no execCtx.tenantId meant no scope then too);
  • recorded none for a non-exempt caller arises only from tenancyDisabled (the engine already excludes the object, before and after, :6887) or objectHasOrgIdField === false (no column, or a federated phantom anchor): the SQL driver resolves no tenant column and scoped nothing before either (sql-driver.ts:15585-15586); federated objects are excluded by isFederated in both versions;
  • a group system context exits plugin-security before recording, so it takes the fallback: organizations over accessible_org_ids with the active organization as tenantId — identical to before when a provider is injected;
  • a lean embedding with no SecurityPlugin and no provider: resolveEnginePosture() (:7037-7044) falls through to resolveTenancyPosture() (types/env.ts:147-163): env unset → single → none (the declared widening); isolated → equality (unchanged); group → the membership union where the old code gave equality (declared direction, missing table row — ②);
  • platformGlobalObjects — made total by ADR-0131 D7: a declared object is registered with no organization column and systemFields.tenant: false, so Layer 0 records none and the SQL driver has no column; isTenancyDisabled (object.zod.ts:759-761) reads tenancy.enabled === false only, so the fallback still hands a system read's tenantId for such an object — harmless at the SQL driver (no column), identical to before;
  • the memory driver boots only under single (seam 1, memory-tenancy-guard.ts:38-39) so under a wall it is moot; under single seam 3 (:299-330) now never sees a read scope and the driver answers every organization's rows — the D8/D11 consequence, stated in the PR's acceptance notes, and writes still arrive with the target and are still refused (pinned, schedule-acting-organization.dogfood.test.ts:592-620);
  • an object with no organization column under a wall: the fallback passes objectHasOrgIdField: undefined ("assume tenant object"), the SQL driver resolves no column — same rows as before.
    No case narrows below Layer 0's where, which is always ANDed in; so no two-answers inconsistency remains in the walled postures.

(c) single: the driver no longer narrows — that is what D8/D11 rule. Right, and stated. D8: "nothing under single — and threads the same value to Layer 0 and to every driver"; D11: "only whether the wall is enforced differs". A single install holding several organizations was isolated by the driver alone, by accident; the PR body (Consequence paragraph), the changeset ("Who notices") and the entry's reason say so. Where an operator reads it: the changeset → CHANGELOG.md of objectql/core/spec at release; the entry → the generated upgrade guide (build-upgrade-guide.ts:135-137). Not stated: a hand-written sentence in content/docs/deployment/tenancy-modes.mdx (whose single row at :352 says "exactly one organization" and never says the driver enforces nothing) — docs carrier in ③. D9 already refuses an organization-less system write on that topology (ambiguous-organization, core system-write-organization.ts:45-51), so the affected topology is one the record already treats as unsupported for writes.

(d) Writes. Right. The insert path passes { writeTarget: true } (engine.ts:15936-15944) and driverWriteTarget (:7006-7013) returns the active organization in every posture, with the group set beside it; resolveSystemInsertOrganization's derived id still wins through base.tenantId. The SQL driver's injectTenantOnInsert (sql-driver.ts:15652-15664) stamps from it and the autonumber counter scopes by it (:8255-8260). There is no engine upsert door (ENGINE_UPDATE_UPSERT_REMOVED, engine.ts:848-853); the SQL driver's upsert() is reached only by direct driver callers (e.g. lifecycle-service.ts:1474 with no options), so its cross-organization conflict refusal (upsertTenantGuard :8912-8925 → assertMergeLandedInWrittenOrganization :9387-9425 → refuseUpsertConflictOutsideWrittenOrganization) is untouched by construction — the PR body's "insert and upsert pass writeTarget: true" is imprecise on the mechanism, the changeset's "still refused" is true. Pinned both ways (engine-driver-read-scope.test.ts:388-392, :468-472). By-id update and delete take the read scope (:17308, :19818; the cascade rebuild at :19860 re-runs buildDriverOptions on options that already carry the scope, so it adds nothing), which is the declared widening under single.

(e) Engine-side fallback. Right; the env-unset case is single. resolveEnginePosture() prefers the injected provider (the tenancy service's enforced answer), else resolveTenancyPosture(): OS_TENANCY_POSTURE unset → resolveMultiOrgEnabled() ? 'isolated' : 'single' (types/env.ts:163); an unrecognised value throws and the engine degrades to isolated (fail toward the wall). So an embedding that mounts no SecurityPlugin, sets no posture, and passed context.tenantId to narrow reads is widened to no scope — the declared Clause-② widening, carried by the changeset and the upgrade guide, with no runtime signal (③). It is the same reading the engine's write refusals already use (:7197), so Layer 0 and the engine cannot disagree on posture.

(f) The semantic entry. Shape and registration right; one wording under-statement (non-blocking). export const entry: SemanticMigration with id, surface, replacement, reason, acceptanceCriteria matches SemanticMigration (types.ts:99-112) and the precedent 18.action-bulk-dispatch-contract-undeclared.ts; the filename equals shardNameFor(18, id) (build-migration-registry.ts:182-183) and the id line matches the generator's ^ {2}id: '…',$ read (:232); it registers by directory into the semantic:18 region (registry.ts.template:3367) with no index; check:spec-changes/check:upgrade-guide are green on the head. No tracker number in any shipped string. Wording: reason and replacement state D8 and D11 correctly and name both arms left standing; surface reads "the update and delete predicates", but by-id update and delete lose the single scope as well (:17308, :19818 — a by-id operation records no verdict, so the fallback answers none under single); the changeset table ("update or delete") is right, the entry under-states. The surface field's contract is "dotted surface … e.g. object.titleFormat"; this one is a sentence rendered inside a code span (build-upgrade-guide.ts:135) — legible, not wrong.

(g) #15831 closes by pointer. Right. Its tripwire tenant-layer0-verdict-end-to-end.test.ts:181 reads toBe(2) on main already (D7 made the carve-out structural); with this PR the driver leg is the wall's verdict, so the two legs agree by construction, and the issue's own re-check (platformGlobalObjects in packages/drivers/packages/objectql/src) hits only the registry's column-plan reading and prose, no driver code. The PR carries Part of #15212 and no closing keyword for #15831 — the pointer close is the seat's act after merge, as the plan row says.

② Semver level

  • Arm: yes (widening) — right; nothing is narrowed. Every pre-PR driver scope is either kept (walled member, walled system context naming an organization, group member with a provider) or widened to what Layer 0 admits (single → none; exempt PLATFORM_ADMIN on a posture-permitting object and the verified owner's read → none). No FROM → TO pair narrows, so yes (narrowing), BREAKING and an ADR-0087 disposition are not owed; check-adr-0087-registration reads the changeset arm only (breakingDeclaration, :673-686: narrowing is the signal, widening adds none) and check-changeset-no-major holds yes to minor or above — both green.
  • One widening row the table omits (non-blocking): no injected posture provider (no tenancy service), OS_TENANCY_POSTURE=group, a context carrying accessible_org_ids → before: equality on the active organization (old engine.ts:6833-6838 kept tenantIds provider-gated, "fail toward isolation"); now: the membership union (resolveEnginePosture() reads the env). It is Layer 0's group predicate, so it is inside the declared arm; the entry's "the membership set on group" covers it generically. Add the row to the changeset table if another commit is made on this head; otherwise record it on the card.
  • Levels match what the diff publishes. @objectstack/core minor: two new root exports (computeTenantLayer0Verdict, TenantLayer0Input, core security/index.ts:43-46) — a public-surface addition, graded. @objectstack/objectql minor: the behaviour widening. @objectstack/spec minor: the new registry entry (and yes takes at least minor). @objectstack/plugin-security patch: tenant-layer.ts now imports from core and re-exports the two names from the module — the package index exports neither (grep of plugin-security/src/index.ts: none), its Layer 0 ladder is byte-identical, so no public surface of that package moves. @objectstack/driver-memory: docblock-only hunks, no changeset owed. A2 holds: core depends on spec and types only; objectql and plugin-security both already depend on core.
  • Line spelling. PR body Clause-②: yes, changeset Clause-②: yes (widening) — clause2-line.mjs reads the two as one spelling ("yes / yes (widening) — a widening. The second spelling is the first"); no gate compares the body line to the changeset line byte-for-byte. Wrong text: none.

③ Boundary flags

Open questions of report 6254147295:

  1. Posture reading (A, resolveEnginePosture) — answered right: D8 derives from "posture and context", and it is the posture the engine's own D9 write refusals read; the env-unset default is single (types/env.ts:163). Flag carried: the no-provider + env group row above (②).
  2. Deny projection (A, active organization if present) — answered right: where the wall ran, the deny sentinel is in the where; where it did not, deny arises only under group with an empty set (equality kept) or isolated with no active organization (no scope, as before). No carrier.
  3. objectHasOrgIdField: undefined in the fallback (A) — answered right for every driver in reach: the SQL family resolves its own column; the memory driver cannot boot under a wall. No carrier. (Note: isTenancyDisabled does not read systemFields.tenant === false, so a D7-declared platform-global object on a system read still gets a tenantId the SQL driver ignores — pre-existing, harmless.)
  4. PR line spelling (A) — answered right (②).
  5. Dogfood schedule suites set the engine's provider to isolated around the bind (A) — acceptable as a test-side device; the fixture now models env isolated + engine isolated while the stack's tenancy service answers single, a split model the suites' own comments disclose. The honest fixture (B, boot walled with the organizations plugin) is a dogfood-lane candidate; no card needed now.

Deviations:

  • @objectstack/rest full suite not run locally — Test Core (six shards, all success) owns it on the head; no carrier.
  • Commit trailers — the 8 branch commits carry neither Claude-Session: nor Co-authored-by: (counted: 0/8). The landing is a squash; the squash commit message must carry the model-free pair. Carrier: the owning seat at landing (the pre-push hook refuses a model identifier; landed history is not rewritten).
  • Worktree detach, with-fleet.sh --read refused (GET-only gh reads), background test loops — reporting only.

Out-of-scope findings:

  1. packages/verify/src/harness.ts inviteForAudienceGate comment is stale (says audience-gate invitations are invisible to fixtures; on single an owner now reads them). A one-line docblock correction in a published package; carrier: the next C8 stage that edits packages/verify (8b-0b's follow-up or 8c-T), named by plan amendment — a stale comment, not a defect card (filing gate ① is not met).
  2. [tenant-audit] WARN for an exempt PLATFORM_ADMIN's cross-tenant predicate update/delete under a wall — auditMissingTenant (sql-driver.ts:15677-15707) fires once per object:op when tenantId is absent and isMultiTenantMode(); its prescription "Pass tenantId via ExecutionContext" is now wrong for that case (the context carries one; the wall lifted it). Carrier: stage 8c-1 (the chokepoint and audit rework), as the report proposes; the engine could also pass bypassTenantAudit: true when the recorded verdict is none and the context names an organization — 8c-1's design call.
  3. time_relative sweep under single with several organizations — a D8/D11 consequence settled by the ADR; the suite now pins the walled model. No carrier.

Further non-blocking items from this review, each with a carrier:

  • Entry wording (①f): surface should name update and delete "by id or by predicate". Carrier: stage 8e, which edits packages/spec/src/migrations and already carries the stage-9 entry's D13 wording (claim 6254514683).
  • PR body mechanism wording (①d): "insert and upsert pass writeTarget: true" — there is no engine upsert door; the driver's refusal holds by construction. A body edit by the seat; nothing ships from it.
  • Operator docs: content/docs/deployment/tenancy-modes.mdx should state that single enforces no organization wall at any layer, the driver included, and that organizations in one single install are not kept apart. Carrier: a docs-only PR or stage 8c-1 (the docs-drift run listed 26 affected pages on this head).
  • No runtime signal for the single widening (①e): an embedding that relied on the accidental narrowing learns of it only from the CHANGELOG and the upgrade guide. Accepted as D11's consequence; the release notes (release-owned, compiled from this changeset) lead with it.

Implemented-by: claude/issue-15212-8a-one-predicate
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

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

Development

Successfully merging this pull request may close these issues.

2 participants