Skip to content

feat(plugin-security)!: under a walled tenancy posture every external object states its organization scope - #29867

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/sec-hardening-g
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/sec-hardening-g

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Clause-②: no (narrowing) — under the isolated and group tenancy postures an external object (an external block) that states no organization scope is refused by name when the boot completes; one registered after the boot is denied at Layer 0 for reads and every insert, update and delete of it is refused at the write wall. Nothing is widened, and the single posture is unchanged.
Metadata-protocol: narrowed
Skills-docs: follow-up #29866 — content/docs/data-modeling/virtual-objects.mdx (a multi-organization section), skills/objectstack-data/rules/datasources.md (the three declarations), docs/adr/0015-external-datasource-federation.md (the §10 and D7 amendment); the sibling docs PR on claude/sec-hardening-g-docs, governed (Tier H), landed by the maintainer's hand. Both docs mention the write refusal; the whole-block systemFields: false is not a declaration.

维护者速读

  • 改了什么:在 isolated / group 租户姿态下,每个外部对象(带 external 块)必须声明自己的组织范围——三选一:自带 organization_id 字段(远端行带该列、驱动返回它;另名远端列用 external.columnMap 映射到它)、tenancy: { enabled: false }(全组织共享)、systemFields: { tenant: false }(同一声明,写在系统字段上)。整块 systemFields: false 不算声明;对象自带 organization_id 时,除非显式退出,一律按有墙处理。什么都没声明的外部对象在启动完成时(kernel:ready)按名拒绝启动;启动后才注册的(Studio 导入、运行时数据源)在 Layer 0 判 deny(每次读零行),写墙在第一步就拒绝它的每一次增删改(拒绝文案指出未声明的范围与三种声明),并告警一次。外部对象上的 tenancy.tenantField 同样按名拒绝(墙只认 organization_id)。showcase 的两个联邦对象声明为共享。
  • 为什么改:外部对象没有平台存储,注册表注入的 organization_id 只是声明不是列;它的行属于一个组织还是所有组织,只有作者知道。规则由安全频道裁决(D1 B,D2 (c2)+(a));single 姿态不受影响。
  • 风险与回滚:破坏性(!):有墙姿态下存量的未声明外部对象会拒绝启动,错误信息列出对象名与三种声明;改元数据即可,不需要迁移数据。回滚 = 回退本 PR。
  • 先看:① packages/plugins/plugin-security/src/external-organization-scope.ts(一处判定 + 启动拒绝文案);② packages/core/src/security/tenant-layer0-verdict.ts(新的 deny 一阶);③ 写墙 composeWriteTenantWall 的未声明拒绝(undeclaredExternalScopeWriteRefusal);④ examples/app-showcase/src/data/objects/external/*.object.ts(声明)。
  • 两分钟验证:OS_TENANCY_POSTURE=isolated OS_PLATFORM_OWNER_EMAIL=you@example.com pnpm --filter @objectstack/runtime exec vitest run src/plugin-driver-external-object.spike.test.ts —— 预期 19/19 通过,其中 (h6) 断言拒绝启动的错误按名列出对象与三种声明,(h5b) 断言启动后注册的未声明对象的写入被拒。
  • 需要你做的:无,席位落地(本 PR 不触及受管面;配套的 ADR/文档 PR docs(adr): under a walled tenancy posture every external object states its organization scope (ADR-0015 §10) #29866 另行由你落地)。

What changes

Under a walled tenancy posture every external object states its organization scope (ADR-0015 §10, amended by the sibling docs PR).

  • @objectstack/plugin-security — external-organization-scope.ts is the one reading of the declaration (walled / shared / undeclared / tenant-field-unenforceable), the boot refusal (ExternalObjectOrganizationScopeUndeclaredError, from SecurityPlugin's kernel:ready handler beside the ADR-0131 D5 refusal; a context without hooks judges at the end of start()), and the three ways out as every door spells them. getObjectSecurityMeta reads the same predicate into the Layer 0 input and warns once for an object registered after the boot that states nothing.
  • The write wall — composeWriteTenantWall refuses every insert, update and delete of an object registered after the boot that states no scope, as its first pre-test (ahead of the no-active-organization pre-test), whenever the caller's Layer 0 (or the delegator's) is the deny sentinel. The refusal (undeclaredExternalScopeWriteRefusal) is a rule-level sentence naming the posture, the object, the undeclared scope and the three declarations; it never answers with the "join an organization" sentence.
  • The declarations — only the wall's two opt-out clauses (tenancy: { enabled: false }, systemFields: { tenant: false }) and an own organization_id count. The whole-block systemFields: false is in neither opt-out clause and states nothing; an object with its own organization_id is never answered shared unless it opted out (systemFields: false with an own column reads walled).
  • @objectstack/core — TenantLayer0Input.externalOrganizationScopeUndeclared (optional); set, computeTenantLayer0Verdict answers deny under a walled posture, after the tenancy opt-out and ahead of the column question and the platform-admin crossing. Omitted, every existing caller reads as before.
  • @objectstack/spec — ADR-0087 D3 semantic entry external-object-organization-scope-declared, telling an upgrading operator what to declare; its acceptance text names the kernel:ready / withheld-hooks registrations as met by the denial.
  • examples/app-showcase — showcase_ext_customer and showcase_ext_order declare tenancy: { enabled: false } (the remote tables carry no organization column).
  • tenancy.tenantField on an external object is closed by refusal, not enforcement: the wall keys on organization_id alone, the one reader of a declared tenant field is storage the platform provisions, and external.columnMap already reaches the wall's column. Enforcing it would need the verdict schema and the deny sentinel to carry a second column.
  • Not touched: @objectstack/objectql (the engine already withholds the driver's native scope for external objects; the floor re-applies the wall's where).

Tests

  • packages/runtime/src/plugin-driver-external-object.spike.test.ts block (h), under isolated with two organizations: (h1) A reads only A's rows of a walled object and the driver is asked with the organization predicate; (h2) a driver that ignores where stays walled; a row without the column reads to no one (h1, h2); (h3) tenancy.enabled: false and systemFields.tenant: false objects are shared; (h4) a local object stays walled; (h5) an object registered after the boot that states nothing is denied (the spec's deny sentinel, by identity, for both organizations; the remote is asked for nothing it holds); (h5b) the same object's writes over HTTP — POST answers 403 PERMISSION_DENIED with the write wall's sentence, PATCH and DELETE stay refused, and the remote records no write; control: the same caller's insert on a declared shared object lands; (h6) a boot holding an undeclared object, and one declaring a foreign tenantField, is refused by name with the three ways out. Blocks (a)–(g) boot single with undeclared objects and are the control.
  • packages/qa/dogfood/test/external-object-scope-after-boot-writes.dogfood.test.ts (new): the write refusal at the dogfood door (showcase plus a writable plugin-driver datasource).
  • packages/plugins/plugin-security/src/external-organization-scope.test.ts: the predicate (every declaration, both fields shapes, the parsed-copy rule, systemFields: false stating nothing, an own column with and without the opt-out), the refusal text, the boot door through the plugin (kernel:ready, no-hook, single), the Layer 0 door (isolated, group, tenantField, declared shared, declared organization_id, single, local control), the write wall through writeWallVerdict (caller with an organization, caller without one, group, shared control, single control), and the once-only warning.
  • packages/core/src/security/tenant-layer0-verdict.test.ts: the new rung, its order, and the omitted-flag control.
  • Updated: federated-tenant-layer0.test.ts and tenant-layer0-verdict-on-operation.test.ts (the undeclared external object now records deny on a delete sweep and is refused on an update sweep), four dogfood files whose premise was the showcase object's injected organization_id (it now declares its scope; federated-rls-injectors gains the after-boot deny case).
Suites and typechecks (final trees, under the shared verify lock)
run reading
pnpm --filter @objectstack/plugin-security test Test Files 242 passed · Tests 5242 passed, 45 skipped · command-exit 0
pnpm --filter @objectstack/core test Test Files 133 passed · Tests 2941 passed · command-exit 0 (core unchanged since)
pnpm --filter @objectstack/runtime test Test Files 535 passed · Tests 8573 passed, 19 skipped · command-exit 0 (earlier round); spike file 19 passed (19) incl. (h5b) at the final head
pnpm --filter @objectstack/spec exec vitest run src/migrations Tests 196 passed · command-exit 0
pnpm --filter @objectstack/spec check:generated all 14 generated artifacts up to date · command-exit 0
typecheck — runtime, core, plugin-security command-exit 0 each (check:test-typecheck OK in each)
unit files external-organization-scope + federated-tenant-layer0 + tenant-layer0-verdict-on-operation 64 passed
pnpm --filter @objectstack/dogfood test (full battery) Test Files 408 passed, 3 skipped (411) · Tests 3365 passed, 28 skipped (3393) · command-exit 0 · 29m43s held; the one file changed after the battery started (the new write-door file) re-run alone at 554573c4f4: 4 passed
One-shot ablations (mutate → rebuild → dist preflight → red → restore from HEAD → rebuild → preflight absent → green)
  • Boot refusal (external-organization-scope.ts, the throw guarded by a literal the posture can never equal): marker present in plugin-security/dist (preflight exit 0); boot-door pins 4 failed / 12 passed, spike (h6) 1 failed → restored, git diff HEAD empty, blob hash equals HEAD, preflight --absent exit 0, pins 16 passed, (h6) 1 passed.
  • Layer 0 rung (tenant-layer0-verdict.ts, the deny guarded the same way): marker present in core/dist; core verdict pins 3 failed / 4 passed, spike (h5) 1 failed → restored (same proofs), 7 passed, (h5) 1 passed.
  • Write wall (A3b, the refusal guarded by a literal the operation can never equal): unit "the write wall" 3 failed / 2 passed, spike -t h5 1 failed / 1 passed ((h5b) red, (h5) green), dogfood insert pin 1 failed → restored (same proofs), 5 passed, 2 passed, 1 passed.

Acceptance notes

  • The boot refusal runs at kernel:ready rather than inside start(): the registry is still filling during start() (AGENTS.md, startup registry reads), and the identity-object refusal in the same plugin already judges there. The dispatch said "in start() beside the precedent"; the subscription is placed exactly there.
  • The verified platform owner's read crossing still lifts the read wall for an undeclared object registered after the boot (a standing ruling, recorded by the contract review as the maintainer's question and not changed here); writes by the owner are refused like everyone's.
  • An author-time os validate warning for an external object stating no scope would be a fourth door (now refused loudly at boot); not added here. carrier: none.
  • packages/objectql/src/engine-external-tenant-scope.test.ts and packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts copy the showcase object's former shape in local fixtures and say so; they are fixtures of that shape, not mirrors, and are left as they are.
  • The spike file's test-budget is re-pinned from a local ratio (two boots became three); the first scheduled run that carries block (h) gives the CI reading.

Verification

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 554573c4f4 derived 98 families; every command was run byte-for-byte with its exit captured before any pipe, and --ran reconciled: 98 derived, 98 run, 0 NOT MEASURED, 0 UNRUN.

  • pnpm check:objectql-double-limit was red on the previous head (the new dogfood driver's find ignored limit); it now honours limit / offset (554573c4f4), re-run exit 0.
  • Every family CI runs on the PR is read from the PR's check-runs, not re-run here.

… object states its organization scope

Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG
Co-authored-by: Claude <noreply@anthropic.com>
…r external objects

Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG
Co-authored-by: Claude <noreply@anthropic.com>
…ation scope; an undeclared one registered after boot is denied

Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG
Co-authored-by: Claude <noreply@anthropic.com>
…e engine refuses; the pinned ledger learns it

Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/objects.mdx (via tenantField (symbol, a field of interface UndeclaredExternalObject))
  • content/docs/protocol/objectql/schema.mdx (via tenantField (symbol, a field of interface UndeclaredExternalObject))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via tenantField (symbol, a field of interface UndeclaredExternalObject))
  • content/docs/releases/v15.mdx (via tenantField (symbol, a field of interface UndeclaredExternalObject))
  • content/docs/releases/v17/17-5.mdx (via tenantField (symbol, a field of interface UndeclaredExternalObject))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 38 pages)
  • 9 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); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 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 — 179 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 7b2bb668dacc8d915c161791facf20be78138a99 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7b2bb668dacc8d915c161791facf20be78138a99

⚠️ 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 7b2bb668dacc8d915c161791facf20be78138a99 → 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

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 778365a · 2026-11-03T10:29Z

… as the predicate guard does

Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 29680f9 · 2026-11-03T12:30Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 29680f9ff49d292c22818521a723a528e7b625b8
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.external-object-organization-scope-declared.ts, packages/plugins/plugin-security/src/external-organization-scope.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/tenant-layer.ts, packages/plugins/plugin-security/src/security-plugin-field-readers.ts, packages/plugins/plugin-security/src/index.ts, packages/core/src/security/tenant-layer0-verdict.ts, examples/app-showcase/src/data/objects/external/customer.object.ts, examples/app-showcase/src/data/objects/external/order.object.ts, .changeset/sec-hardening-g-external-object-organization-scope.md, scripts/adr-anchors/packages__plugins__plugin-security__src__external-organization-scope.ts.json, scripts/engine-double-contract.pinned.json, packages/runtime/src/plugin-driver-external-object.spike.test.ts, packages/plugins/plugin-security/src/external-organization-scope.test.ts, packages/core/src/security/tenant-layer0-verdict.test.ts, packages/plugins/plugin-security/src/federated-tenant-layer0.test.ts, packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts, packages/plugins/plugin-security/src/object-membership-keys.test.ts, packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts, packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts, packages/qa/dogfood/test/federated-sweep-projections.dogfood.test.ts, packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts + packages/plugins/plugin-security/src/federated-phantom-anchors.ts, packages/plugins/plugin-security/src/security-plugin-rls-filter.ts, packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts, packages/plugins/plugin-security/src/security-plugin-object-inputs.ts, packages/plugins/plugin-security/src/suggested-audience-bindings.ts, packages/plugins/plugin-security/src/object-membership-keys.ts, packages/plugins/plugin-security/src/metadata-change-invalidation.ts, packages/objectql/src/engine-driver-options.ts, packages/objectql/src/external-driver-floor.ts, packages/objectql/src/engine.ts, packages/objectql/src/registry.ts, packages/objectql/src/plugin.ts, packages/metadata-protocol/src/protocol.ts, packages/core/src/kernel.ts, packages/core/src/lite-kernel.ts, packages/core/src/hook-dispatch.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/data/injected-system-column-provenance.ts, packages/spec/src/security/tenancy-posture.ts, packages/types/src/env.ts, packages/drivers/driver-sql/src/sql-driver-schema-sync.ts, packages/drivers/driver-odata/src/odata-driver.ts

① Derived judgments

Read in the tree at the head above, against the maintainer's seven rulings and the two choices taken at seat discretion. The check-runs on this head are the gate verdict: every required context is success.

  1. Every external object states its scope under isolated / group. externalOrganizationScopeOf is the one reading: an own organization_id (any definition other than the shipped anchor, by hasPhantomTenantAnchor's identity test), isTenancyDisabled, systemFields.tenant: false, and the discretionary systemFields: false. Holds.
  2. Undeclared ⇒ refused by name at boot, naming the ways out. refuseUndeclaredExternalObjectScope, subscribed in start() to kernel:ready, enumerates registry.getAllObjects(); the message names the posture, every object (sorted), the three declarations, the columnMap route onto the wall's column, the restart, and the single carve-out. Both kernels propagate a kernel:ready throw into a failed bootstrap() (dispatchHookPropagating; LiteKernel triggerHookOrThrow). Holds.
  3. Registered after boot ⇒ deny at Layer 0. getObjectSecurityMeta reads the same predicate into externalOrganizationScopeUndeclared; tenantLayer0InputOf carries it; computeTenantLayer0Verdict answers deny after the tenancy opt-out and ahead of the column question and the platform-admin rung, under both walled postures. Every post-boot registration path — Studio writes through metadata-protocol, the objectql plugin's metadata:reloaded re-registration, runtime package registration, a raw registry.registerObject — lands in the one registry the door reads per object name; the meta cache is cleared on onMetadataMutation and metadata:reloaded, and a name never read has no entry. The recorded verdict is what find, count and aggregate project; the driver's native scope stays withheld for federated objects. Holds for reads. For writes see ③ (must-fix).
  4. tenancy.tenantField on an external object closed. A tenantField naming any column but organization_id is refused at boot and denied after; tenantField: 'organization_id' beside an own column reads walled; tenancy: { enabled: true } alone states nothing. Closed by refusal, with the columnMap remedy spelled. Holds.
  5. Showcase declares its scope. Both federated objects declare tenancy: { enabled: false }; the four dogfood premises are re-pinned to the declared shape. Holds.
  6. Reference test and skills/docs. Spike block (h) under isolated with two organizations: walled through a where-honouring and a where-blind driver, a row without the column reads to no one, both shared spellings, a local control, the after-boot deny by sentinel identity, the by-name refusal. Unit pins on the predicate, the refusal, both doors and the once-only warning; the core verdict pins the rung's order. Docs and skill are docs(adr): under a walled tenancy posture every external object states its organization scope (ADR-0015 §10) #29866.
  7. ADR-0015 §10 and D7 — docs(adr): under a walled tenancy posture every external object states its organization scope (ADR-0015 §10) #29866.

Seat discretion, judged: the refusal at kernel:ready, not inside start() is correct — during start() the registry is still filling (AGENTS.md, startup registry reads, cure 1); the ADR-0131 D5 precedent judges an option, not the registry; the identity-object refusal in the same plugin already judges at kernel:ready. A hookless context judges at the end of start(); a host that withholds boot-phase hooks is not refused at boot, and the Layer 0 door holds for it. Accepted. systemFields: false accepted as a declaration: in effect sound — the registry injects nothing for it, so an external object without an own column reaches none exactly as a local one does, and no wall is narrowed (the wall's tenancyDisabled keeps its two clauses, so an object declaring both systemFields: false and an own organization_id stays walled). Two spelling defects flagged in ③.

Attempts to break it: single regression — the verdict's first rung, the refusal's early return and the warning's guard all read postureEnforcesWall; blocks (a)–(g) boot undeclared objects as the control; the default posture is single unless the posture or multi-organization variable is set. None. SQL-federated — the door is per object name, not per driver; the dogfood after-boot case runs on the showcase's SQL-backed datasource; the deny sentinel is an ordinary where the SQL driver runs; columnMap is applied by the SQL and OData drivers. Holds. A declared organization_id judged phantom — only an authored definition byte-equal to the shipped constant, which a parsed declaration never is. Accepted. Refusal firing on a declared object — (h6) and the unit control pin the shared and walled siblings unnamed. None. Timing at kernel:ready — handlers run in subscription order; an object a later plugin registers in its own kernel:ready or kernel:bootstrapped handler is missed by the refusal and met by the deny with its once warning: fail-closed. Accepted. Write path for an after-boot undeclared object — see ③.

② Semver level

BREAKING (!) on the walled postures only. Clause-②: no (narrowing) and Metadata-protocol: narrowed on the body and the changeset; minor on @objectstack/plugin-security, @objectstack/core and @objectstack/spec (the kind's floor on the packages the diff moves); the BREAKING banner carries the FROM → TO (the three declarations, no data migration) and the ADR-0087 D3 marker external-object-organization-scope-declared, matching the entry's id. TenantLayer0Input gains an optional field; omitted, every existing caller reads as before. Level correct.

③ Boundary flags

  • MUST-FIX — the write half of the deny verdict is narrower than four texts claim. The warning (undeclaredExternalScopeDenialWarning), the changeset, the D3 entry's reason and the ADR/guide text in docs(adr): under a walled tenancy posture every external object states its organization scope (ADR-0015 §10) #29866 say every write of an after-boot undeclared object is refused. In the tree the verdict refuses every read and every write whose rows it selects; the composed write wall (composeWriteTenantWall) judges a row's organization only where the row names one, and the engine stamps none on a federated object — so one write shape is not refused by this verdict. Either refuse any write whose caller wall is the deny sentinel outright in composeWriteTenantWall, as its scope pre-test already does, with a pin beside (h5), or narrow the four texts to what is enforced. Detail is with the seat (security-findings §6); it belongs in the no-card dispatch, not here.
  • systemFields: false — (a) the branch is ordered ahead of the own-column branch, so the exported predicate answers shared for an external object the wall walls (declares both); order the own-column test first. (b) The parenthetical "systemFields: false includes it" (module docblock, changeset, ADR note) contradicts the registry's recorded reading that the wall's opt-out has exactly two clauses and systemFields: false is in neither; restate it as accepted on an external object with no own column, not as the wall's opt-out.
  • Platform-owner read crossing. computeLayeredRlsFilter lifts every armed read wall for the verified declared platform owner (standing ruling), the deny sentinel included, so the owner reads an undeclared after-boot object unwalled while the verdict's ladder places this deny ahead of the platform-admin rung. Whether a configuration-defect deny should stand for the owner too is a ruling, not a dev change; recorded, not judged.
  • security-findings §6. The PR body's before-readings paragraph and the changeset's Before bullet state more than the hardening rule; trim both to the rule before landing (the Now and Who-moves bullets carry what an operator needs).
  • A host that withholds boot-phase hooks, and objects a later plugin registers at kernel:ready, are not refused at boot; the Layer 0 door holds for them. Say so in the D3 acceptance criterion or accept as is.
  • The spike's test-budget is re-pinned from a local ratio; the first scheduled run re-measures it. Lane note, not a must-fix.

④ Design judgment

  • One predicate, two doors, judged against federated-phantom-anchors.ts (provenance by identity, fail toward isolation), tenant-layer.ts and tenant-layer0-verdict.ts: the new rung sits after the opt-out and before the column question and the crossing, and externalOrganizationScopeIsUndeclared is the only derivation on both doors, so they cannot disagree. Sound.
  • The boot door's placement against refuseAcceptedDefaultsWithoutWall: the precedent judges a declared option the moment the posture is known; this judges the registry, which is final only at kernel:ready. The deviation from the dispatch's wording is the right one.
  • The operator's path for a production single-database multi-organization deployment: the refusal names the objects and the three declarations with the columnMap route; the D3 entry's surface, replacement, reason and acceptanceCriteria say the same, with no data migration. Adequate, once the write claim is made true.
  • tenancy.tenantField closed by refusal rather than enforcement: right — the wall has one column, columnMap already reaches it, and enforcing would widen the verdict schema and the deny sentinel.
  • engineLayer0Verdict (the engine's own facts) does not carry the flag; federated objects receive no driver scope there regardless. No gap.

Implemented-by: claude/sec-hardening-g
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: FAIL

…nal object that states no organization scope; systemFields: false states nothing

Under a walled posture an external object registered after the boot that
states no organization scope answers no row and accepts no write: the write
wall refuses the insert, update and delete ahead of its scope pre-test, with
a sentence naming the undeclared scope and the three declarations. The
whole-block systemFields: false is not one of the wall's two opt-out clauses
and is no longer read as a declaration; an own organization_id is never
answered shared unless the object opted out.

Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 554573c · 2026-11-03T14:39Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 554573c4f4e71abebfce28cd6d466b998dc1a109
Local-runs: none
Scope: packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts, packages/plugins/plugin-security/src/external-organization-scope.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/spec/src/migrations/entries/semantic/18.external-object-organization-scope-declared.ts, .changeset/sec-hardening-g-external-object-organization-scope.md, packages/plugins/plugin-security/src/external-organization-scope.test.ts, packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts, packages/runtime/src/plugin-driver-external-object.spike.test.ts, packages/qa/dogfood/test/external-object-scope-after-boot-writes.dogfood.test.ts + packages/plugins/plugin-security/src/security-plugin-rls-filter.ts, packages/plugins/plugin-security/src/tenant-layer.ts, packages/core/src/security/tenant-layer0-verdict.ts, packages/objectql/src/registry.ts (delta review of 29680f9ff49d292c22818521a723a528e7b625b8..554573c4f4e71abebfce28cd6d466b998dc1a109; the record on the earlier head is comment 6604182905, FAIL)

① Derived judgments

Delta only, read in the tree; the check-runs on this head are the gate verdict — every required context (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI, Dogfood Regression Gate) is success; Lint UI is path-skipped as before.

  1. Must-fix closed — the write half now matches the texts. composeWriteTenantWall gains a first pre-test, ahead of the no-active-organization pre-test: under orgScopingEnabled, when getObjectSecurityMeta(object).externalOrganizationScopeUndeclared holds and the caller's write-wall Layer 0 (or, failing that, the delegator's) is the deny sentinel, the whole write is refused with undeclaredExternalScopeWriteRefusal — a PermissionDeniedError naming the posture, the object, the undeclared scope and the three declarations, never the join-an-organization sentence; judgePayload and storedImage are not composed. Verified against the ladder: the read door's platform-owner lift is read-only (!isWrite), so the owner's writes stay refused; single yields none, so the pre-test never fires there; the pre-test reads the same cached meta the read door reads, so a local object pays one map lookup. Pinned at three layers: unit (writeWallVerdict: caller with an organization, caller without one, group, shared control, single control), spike (h5b) over HTTP (POST 403 PERMISSION_DENIED with the sentence, PATCH and DELETE 403/404, the remote records no write, the shared sibling on the same datasource lands), and the new dogfood file on the showcase plus a writable plugin-driver datasource (insert refused with the sentence, update/delete reach the remote with no write, member read is the deny sentinel, shared control lands). The on-operation test now records deny on a delete sweep and the 403 sentence on an update sweep. Closed.
  2. systemFields: false corrected. externalOrganizationScopeOf now computes optedOut from exactly the wall's two clauses (isTenancyDisabled, systemFields.tenant === false); a foreign tenantField is judged first unless opted out; shared only when opted out; walled on an own column; else undeclared. The whole-block systemFields: false alone is undeclared (refused at boot / denied after, consistent with the registry's recorded reading and the ruling's three declarations); an own column beside systemFields: false is walled, as the wall walls it; an opt-out beside an own column is shared, as the wall composes nothing there. Unit pins cover all four shapes and the refusal now names such an object. The docblock and the changeset no longer say "systemFields: false includes it". Closed.
  3. Disclosure at rule level. The changeset's Before bullet is gone; "Now" is "What the rule does"; the D3 entry's reason no longer narrates the prior reading and now also names the kernel:ready-late and withheld-hooks registrations as met by the denial (the ③ note from the earlier record, taken). The rewritten PR body carries no before-readings block; its acceptance notes record the owner read crossing as the maintainer's question. Read against security-findings §6: rule only. Closed.
  4. check:objectql-double-limit. The new dogfood driver's find honours limit and offset; Lint & Repo Gates is green on this head. Closed.

Nothing in the delta touches the boot refusal, the Layer 0 rung, the showcase, or the single controls; blocks (a)–(g) still boot undeclared objects under single, and the single write control admits the insert as before.

② Semver level

Unchanged: BREAKING (!) on walled postures; Clause-②: no (narrowing) and Metadata-protocol: narrowed on body and changeset (the Clause-② sentence now states the write refusal); minor on plugin-security, core and spec; the D3 marker matches the entry. The write-wall refusal is a further narrowing on the same walled postures and adds no authorable key. Level correct.

③ Boundary flags

No must-fix. Nits, for the seat's discretion, not for the landing:

  • The changeset and the D3 reason say "every insert, update and delete is refused"; the delete of an undeclared object is closed by the row wall selecting nothing, and (h5b) and the dogfood pin accept 403 or 404 for it. "an update or delete reaches no row" is the exact phrase, or pin the delete at 403 if the door answers it.
  • The new pre-test consults getObjectSecurityMeta for every non-system write under a wall; the lookup is the cached map and costs nothing measurable, noted only so nobody later "optimises" it behind the ADR-0123 pre-test, which would reopen the gap.
  • Platform-owner read crossing: unchanged from the earlier record — a maintainer decision-batch item, not a must-fix here.

④ Design judgment

The fix is in the right door: the write wall's own verdict (isTenantWallDenial over the same Layer 0 the read side computes), applied whole and first, so the deny sentinel means the same thing for a write as for a read, and the rows-that-name-an-organization halves underneath keep their existing meaning for walled objects. The predicate's new order mirrors the wall's own clauses and the registry's recorded reading, so the exported label, the boot refusal, the Layer 0 flag and the write pre-test cannot disagree about an object. The texts an operator reads — the boot refusal, the once warning, the write refusal, the D3 entry and the changeset — now claim exactly what the tree enforces.

Implemented-by: claude/sec-hardening-g
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head 554573c4f4e71abebfce28cd6d466b998dc1a109 · 2026-11-03T14:46Z

  • Shape: 24 files, +1736/−85, not governed (0 of 24 paths hit the register), under 3000. BREAKING (!) under the walled postures; Clause-②: no (narrowing), Metadata-protocol: narrowed; minor on plugin-security, core and spec.
  • Rule held (the security channel's ruling, D1 B, D2 (c2)+(a)): under isolated and group every external object states its organization scope. An undeclared one is refused by name at boot; one registered after the boot is denied at Layer 0 for reads and refused at the write wall for writes. single is unchanged.
  • Contract review: the delta record, comment 6606840268, carries Served-tier: CONTRACT_REVIEW_TIER, a Head-sha equal to this head, and VERDICT PASS with no must-fix. It follows the FAIL at 6604182905, and the same reviewer closed both of that record's must-fixes.
  • Check-runs at this head: 57 success, 11 skipped, 0 failed, all completed. The skips were judged by name: opt-in, path-gated, and the matrix placeholder. check-expected-skips.mjs was NOT MEASURED. mergeable_state reads clean.
  • Gates: local dispatch-gates --ran at this head reports 98 derived and 98 run, with 0 NOT MEASURED. The full dogfood battery passed, with 408 files and 3365 tests.
  • Nits left for the seat (not for this landing): the delete wording ("an update or delete reaches no row"); and the write pre-test must stay ahead of the ADR-0123 pre-test.
  • Maintainer's question (decision batch, not changed here): the verified platform owner's read crossing on an undeclared after-boot object.
  • Sibling: docs(adr): under a walled tenancy posture every external object states its organization scope (ADR-0015 §10) #29866 (Tier H, ADR and guide) has a PASS record at 6606851344 and lands by the maintainer's hand, after this PR or together with it.
  • Landing: ready, then auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 14:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 21510bb Nov 3, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/sec-hardening-g branch November 3, 2026 15:13
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant