Skip to content

fix(objectql,plugin-security): the write preview answers a row's organization from the tenant wall's own verdict - #25215

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-qv7hgm
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-qv7hgm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

Fixes #25062

Clause-②: yes (widening): one additive registration member on @objectstack/objectql's plugin-facing API
Metadata-protocol: none

Builds ruling 6336640643 (letter A, confirmed by the maintainer in 6336790186). Claim 6339442996, Ruling-ref: 6336640643. The plugin-security half is the cross-lane touch declared on #21118 (comment 6339470244).

Summary

ObjectQL.validate() (the import dry run) runs no middleware, so the Layer 0 tenant write wall never judged a preview row. On a walled deployment the preview reported organization_id as dropped where the write stores it as sent (group, a sister organization), and answered valid for a row the write refuses with 403 (isolated, a foreign organization).

Now the wall's own verdict travels to the engine as a value, per row, and the preview reads that value and nothing else:

Pins

Pin File What it holds
The two cells + caller state + controls, in process packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts (10) Real ObjectQL, SqliteWasmDriver, OrganizationsPlugin and SecurityPlugin. Each case runs the write and the preview on the same row. Cell (a): group sister and isolated own organization, stored as sent and no drop in the preview. Cell (b): isolated foreign and group outside the membership set, write 403 and preview valid: false with the write's code and message under organization_id. Caller state under _record: ADR-0123 D2, #24784, #24821. Controls: single posture, a non-tenant object (tenancy.enabled: false), and nothing registered (security: false)
The HTTP import door, M5a / M5b packages/rest/src/import-dryrun-tenant-wall-parity.test.ts (4) The real route POST /api/v1/data/:object/import, the same rows dry then committed, and the row reports must agree: M5a, M5b, a mixed batch, and the single control
Equivalence: seam = middleware packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts (21) 19 table cases across isolated / group / single, member / orgless / group-without-active / operator, insert / by-id update. The real middleware, with the installed seam evaluated by the terminal, gives the same admit / refuse / judgesOrganization / field as writeWallVerdict. Plus a bulk case and the null exits
Engine contract packages/objectql/src/validate-write-wall-verdict.test.ts (14) A stand-in verdict on a fake driver: unregistered / null = today's strip, admitted judged and unjudged, an authored readonly organization_id keeps its strip, a refused row mid-batch, _record, asked once after defaults with no hook run, asked again for a stripped row, update mode, and a wrong-length or throwing verdict rejects

engine-insert-wall-judged-organization.test.ts keeps its assertion; its last title now says "with no write-wall verdict registered", which is what it pins.

Ablation (one-off, not kept)

The implementation was committed first (3d071598). Then the plugin's feature-detect was renamed on disk to registerWriteWallVerdictAblated, so the seam is never registered, and @objectstack/plugin-security was rebuilt. scripts/ablation-dist-preflight.mjs confirmed the marker was in dist.

  • Organizations pins went 7 red (both cells and the three caller-state cases) and 3 green (the three controls).
  • REST pins went 3 red (M5a, M5b, the mixed batch) and 1 green (the single control).
  • Every failure was an AssertionError.

Restored with git checkout HEAD --. git diff HEAD is empty, the package was rebuilt, and the preflight's --absent leg passed.

Verification

All at head 0cc6f290a unless noted. Every heavy command ran under os-verify-lock.

  • Pins: objectql validate-write-wall-verdict, engine-insert-wall-judged-organization and federated-injected-column-readers 32/32; plugin-security write-wall-verdict-equivalence 21/21; organizations validate-preview-wall-verdict 10/10; rest import-dryrun-tenant-wall-parity 4/4. Lock VERDICT command-exit 0.
  • Package suites:
    • @objectstack/plugin-security full: 207 files, 4253 passed, 45 skipped, after the extraction. This is the behaviour-preservation check for the moved step 3.7.
    • @objectstack/objectql full: 8662/8663 at 3d071598. The one red was federated-injected-column-readers.test.ts: the new WriteWallRowVerdict type names organization_id at module scope. It got its not-a-read row in 0cc6f290 and is green at head.
  • Typecheck: typecheck including check:test-typecheck for objectql, plugin-security, organizations and rest. All OK, with no new test-typecheck debt (the shrink-only ledger held for objectql; 0 entries for the other three).
  • Derived gates: dispatch-gates.mjs --commands derives 106 families. All 106 ran, and --ran with exit codes recorded reads 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN. Every recorded exit is 0. Some needed a rerun for reasons outside this diff:
    • check-engine-split-ratio and the check-plugin-teardown-shape self-test refused on the shallow clone and passed once it was deepened.
    • check:i18n, check:dual-build-cjs-loads and check:skill-examples refused for missing dist/ and passed after their named build closures.
    • check:query-options-erasure once raced a concurrent .examples-build writer and passed alone.
    • check-system-context-census was a real finding, fixed by row 6c.
    • The ratchet and census families were re-run at 0cc6f290a.
  • Full dogfood: n/a. The title has no ! and the Clause-② arm is widening.
  • CI: pending at open; read from this PR's check-runs.

Acceptance notes

  • The probe docblock finding (from report 6336244213). The registerWriteGateProbe docblock listed "its ADR-0123 D2 organization wall (step 3.7)" among the arms the probe runs. The probe only asks step 3.7's scope pre-test, with no row. It does not ask the supplied-organization forge guard, and it answers true for a foreign organization (measurement M3). The docblock now says so, and points to registerWriteWallVerdict for the row judgement. Two questions, two members: the probe's boolean is unchanged.
  • Changeset level: patch, not the minor the dispatch named. The ruling sets the level "per AGENTS.md Post-Task Checklist step 3", and check-changeset-no-major holds every entry of a Metadata-protocol: none changeset to patch. The new member is optional and additive.
  • The dry run names the field; the commit does not. A refused dry-run row carries field: organization_id (or _record). The commit's row is built from a thrown 403, which has no field on it. Code and message are identical on both doors (M5b). The HTTP pin compares everything but field, and pins the dry run's field on its own.
  • Named limit, kept from the ruling. The preview judges the row as it holds it, after defaults and before hooks. An organization written by a beforeInsert / beforeUpdate hook is judged by the write's stored-image half alone. This is named in the validate() docblock.
  • null exits. writeWallVerdict answers "not asked" for a system context, a context with no user, and a caller the context prologue refuses before the wall is reached: principal-less, guest without an organization, a subsystem failure, or a missing or non-member delegator. The write refuses those callers before its wall, and the preview runs none of the write's caller gates.
  • File surface. The measurement probe validate-preview-wall-verdict.probe.test.ts is deleted (941fa4e1), and its readings are now the pins above. The surface also grew by two ledger rows the repo's own guards require, beyond the claim's list. One is system-context.mdx row 6c: check-system-context-census refuses an elevation read that no row anchors, and writeWallVerdict reads isSystem. The other is a federated-injected-column-readers.test.ts READERS row for the verdict type's organization_id literal.

Generated by Claude Code

… walled write)

Measurement-only commit: records what origin/main does today for the
import dry run's preview of a walled insert, and what reading the wall's
verdict would cost. Not a pin; deleted before any PR.

Claude-Session: https://claude.ai/code/session_0157EiYDGYnrsXE9aHv1uMxU
Co-authored-by: Claude <noreply@anthropic.com>
Measurement-only: the same two cells through POST /api/v1/data/:object/import,
dry run against commit. Deleted before any PR.

Claude-Session: https://claude.ai/code/session_0157EiYDGYnrsXE9aHv1uMxU
Co-authored-by: Claude <noreply@anthropic.com>
The probe recorded what origin/main did (readings M1-M5 on f7d7403) for
the needs_decision report. Ruling 6336640643 (letter A) turns those
readings into pins, which land with the fix; the measurement file itself
is not a pin and goes first, as the resume order requires.

Claude-Session: https://claude.ai/code/session_0157EiYDGYnrsXE9aHv1uMxU
Co-authored-by: Claude <noreply@anthropic.com>
…nization from the tenant wall's own verdict

validate() runs no middleware, so the Layer 0 tenant write wall never judged
a preview row. On a walled deployment the import dry run reported a caller's
organization_id dropped where the write stores it as sent, and answered valid
a row naming an organization the write refuses 403.

plugin-security: step 3.7's judgement is composed by one method,
composeWriteTenantWall (the scope pre-test with its delegator leg, the parts,
the forge guard, the emptied organization, the no-active-organization insert
arms, and judgesOrganization). The middleware's payload half and stored-image
seam call it, with unchanged refusals and log lines. writeWallVerdict answers
each preview row from the same method, and is registered on the engine.

objectql: registerWriteWallVerdict, an optional member beside
registerWriteGateProbe. validate() asks it before the strips and again for a
row the strips changed, as the write asks its wall before and after them. A
judged organization is spared from the static readonly strip; a refused row
answers valid false with the write's code and message, under organization_id
or _record, and drops nothing. Unregistered, the strip runs as before.

The registerWriteGateProbe docblock no longer says the probe runs step 3.7's
organization wall: it asks only the no-active-organization pre-test.

Clause-②: yes (widening)
Metadata-protocol: none

Claude-Session: https://claude.ai/code/session_0157EiYDGYnrsXE9aHv1uMxU
Co-authored-by: Claude <noreply@anthropic.com>
check-system-context-census requires a row for every elevation read; the
new writeWallVerdict answers "not asked" for a system context, as the
elevated write meets no tenant wall.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157EiYDGYnrsXE9aHv1uMxU
…s row

WriteWallRowVerdict names organization_id as the field a refusal answers
under. It is a type, not a storage read of the injected column.

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

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/plugin-security, touching 15 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/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.

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

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via writeWallVerdict (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v18/18-0.mdx (via SecurityPlugin (symbol, a top-level class))

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/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages), organization_id (literal, 37 pages)
  • 6 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 — 28 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 03de83bfe145eea8f495bcbc9886105715721b3f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 03de83bfe145eea8f495bcbc9886105715721b3f

⚠️ 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 03de83bfe145eea8f495bcbc9886105715721b3f → 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: 0cc6f290aba06b57eec8b0104c7f54902c527409
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/index.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/objectql/src/validate-write-wall-verdict.test.ts, packages/objectql/src/engine-insert-wall-judged-organization.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts, packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts, packages/rest/src/import-dryrun-tenant-wall-parity.test.ts + packages/plugins/plugin-security/src/errors.ts, packages/core/src/utils/import-runner.ts, packages/spec/src/api/protocol.zod.ts, packages/objectql/src/validation/rule-validator.ts, scripts/check-changeset-no-major.mjs, scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json, .github/workflows/lint.yml, .github/workflows/pr-automation.yml. The two changesets are judged under ②; content/docs/permissions/system-context.mdx row 6c is checked for truth against the diff under ③. reviewFace over the PR's file list returns no schema or governed hit (the paths decide no face; the Clause-②: yes (widening) line alone would owe TIER_DEFAULT); the tier served is the one ruling 6336640643 sets for this delivery, a new published seam on the tenant write wall, and the floor is the whole diff outside the prose faces.

Judged against ruling 6336640643 (letter A, confirmed 6336790186), card #25062 and its comments, the PR body with its Acceptance notes, the dev report 6341141274, and the net diff against the merge base 2919ddb875cd0daa38ef452d741bcba04cf366af (7 commits on the branch; the two measurement-probe commits are reverted by 941fa4e18, so the net diff holds no probe). Read-only: no checkout, build, test or gate run.

① Derived judgments

(a) Premise — one method, no second spelling: RIGHT. At head, step 3.7's judgement is composed by SecurityPlugin.composeWriteTenantWall (security-plugin.ts:8333) and the middleware itself calls it at :4110, under the step's unchanged guard (:4091–4096: insert or update, an object payload, a user id). tenantParts is declared once (:8461, assigned :8486) and clearsWall once (:8488); every other occurrence of either name at head is a comment or inside that method (judgePayload :8584, judgeRow :8631). The payload half (wall.preTest ?? wall.judgePayload(writeRows)) and the stored-image seam (wall.storedImage.judgeLanding / judgeRow / judgesOrganization) both read the composed wall. The three helpers the wall is derived from — organizationWallRefusal, computeWriteTenantWall, computeWriteTenantCheckFilter — are byte-identical between base and head, and the body of canWriteObject (the probe's answer) is byte-identical too; only its docblock moved. Engine side: the added lines of engine.ts mention posture, tenantId and accessible_org_ids only inside comments that forbid reading them; validate() reads the verdict value (wallRefusalOf, wallJudgesOrganizationOf, engine.ts:15877–15883) and nothing else. The OperationContext interface is untouched by the diff.

Behaviour preservation of the moved step, read condition by condition: parts computed when writeRows.some(namesOrganization) || this.orgScopingEnabled (was suppliedRows.length > 0 || …, the same set); the forge guard and the emptied-organization refusal gated on walled (tenantParts.length > 0) as before; the seam installed on (walled || operatorInsertLacksActiveOrganization) && (insert || singlePayload) with singlePayload = !Array.isArray(opCtx.data), the base condition verbatim; judgesOrganization: walled && (insert || (update && singlePayload)), verbatim; evaluate order unchanged (operator landing arm, then the #24784 member arm, then the business check, then the per-row loop); each refusal still warns then throws (raiseWriteWallRefusal, :8674) with the base's log line and message text moved whole. The only mechanical difference is that the pre-test's PermissionDeniedError is now constructed at compose time rather than at the throw, which changes no code, status or message. The ADR-0123 anchor's ordering invariant (pre-test ahead of the forge guard) holds in both callers: preTest ?? judgePayload in the middleware, preTest ?? judgePayload ?? judgeLanding in writeWallVerdict (:8745).

(b) Security direction: RIGHT, fail-closed on every exit. The write path's only change is the extraction above, with equal predicates, so the write admits nothing it refused before. The preview changes in two places only: it spares organization_id from the static-readonly strip where the verdict says judgesOrganization (through insertStaticReadonlySubject / updateStaticReadonlySubject, engine.ts:2473 / :2500 — the write's own functions, which fall back to the base subject when the flag is false, so an unregistered or null verdict reproduces the base strip exactly), and it refuses rows the wall refuses. Neither can make a row preview valid where it previewed invalid before: the preview's accept set only shrinks (the refusal), and the drop it stops reporting is one the write does not take. Exits: nothing registered or a null answer → today's strip (ruling item 3, pinned UNREGISTERED and NULL); the seam throws → validate() rejects — askWriteWall (:15864) has no catch, unlike the probe's .catch(() => false) at :16136 — pinned ("a verdict that throws rejects the preview"); a wrong-length answer → throws (:15867). Insert vs update: on update the preview's rows are the caller's payload with its address; judgePayload judges a supplied or emptied organization_id, judgeLanding is insert-only, and the stored-image judgeRow is not asked — right, because a by-id payload that leaves the column alone keeps the organization the row was selected under. writeWallVerdict passes singlePayload: true for every row: right for the preview's rows, which the import commits one update() per row, and the equivalence pin's update cases are by-id single-row cases. The null exits of writeWallVerdict (:8724–8731: a system context, no user id, a caller the context prologue refuses) hand the preview its pre-PR answer for callers the write refuses before its wall — a limit that predates this PR and is not widened by it (the preview ran none of the write's caller gates before either). Observation, not a verdict item, carried to the end.

(c) Prohibitions: KEPT. The probe's boolean is untouched (body byte-identical; registerWriteGateProbe at engine.ts:6336 unchanged; only its docblock corrected). No OperationContext member is added (the interface at :3728 is not in the diff; the plugin registers through a feature-detected engine method, :1978). No ride of the middleware chain (validate() still calls no executeWithMiddleware). No contract text moves (no packages/spec path in the file list; ValidateDataResponseSchema / ValidateDataIssueSchema unchanged).

(d) Deviation 3, the second ask on the stripped row: INSIDE the ruling, and RIGHT. The ruling fixes the seam's signature, that validate() reads the verdict per row and nothing else, and the limit "after the defaults, before any hook". The second ask (engine.ts:15973–15977) asks the same seam, about the same row as the strips leave it — still before any hook — and reads only the verdict; it writes wallRefusals[i] only for a row with no refusal, so it can add a refusal and can never clear one or re-decide the spare. It cannot refuse on the payload half what the first ask admitted: the strips only remove keys, so namesOrganization can only go from true to false and an own-property null / '' cannot appear. What it reaches is the #24821 arm the ruling names: a crossing operator with no active organization naming an organization — first ask admitted with judgesOrganization: false (no parts), the strip takes the column, the stripped row has nowhere to land, judgeLanding refuses _record with the write's message. The write does the same on its stored image. Pinned three ways: the engine contract ("ASKED AGAIN … and only about that row"), in process ([#24821] case: write 403, preview _record, same message), and the equivalence table ("crossing operator with no active organization, a row naming one" admitted on the payload half, as the write's payload half admits it).

(e) Exported surface: RIGHT in kind; the PR body's one-line parenthetical under-names it. The diff publishes: ObjectQL.registerWriteWallVerdict(fn) (a new public method on an exported class), the types WriteWallVerdict and WriteWallRowVerdict (added to the export type line of packages/objectql/src/index.ts), and SecurityPlugin.writeWallVerdict(object, operation, context, rows) (a new public method on @objectstack/plugin-security's exported SecurityPlugin; not a member of ISecurityService, no spec change). All additive and optional: Clause-②: yes (widening) is the right arm on both packages. The objectql changeset names the member and both types; the plugin-security changeset names writeWallVerdict as new. The PR body's single Clause-② parenthetical ("one additive registration member on @objectstack/objectql's plugin-facing API") names the objectql member only — the plugin-security method is published surface too. The declaration line itself is well-formed and the gates read the changesets, so this is a body edit for the seat (③), not a verdict item. The plugin-side WriteWallRowVerdict twin (security-plugin.ts:329) follows the file's WriteImageCheckSeam precedent (the engine is a devDependency) and is welded by the two cross-package pins.

② Semver level

Both changesets grade patch with Clause-②: yes (widening) and Metadata-protocol: none as line-initial declarations. AGENTS.md Post-Task Checklist step 3: the level follows the kind alone — a none takes patch; a non-protocol widening ships patch. check-changeset-no-major.mjs (header: a declared none must grade every entry it introduces patch) runs in the Check Changeset job, which is success on this head. The ruling's execution line set the level "per AGENTS.md Post-Task Checklist step 3"; the dispatch and claim's minor was the dispatch's own word, not the ruling's, and the dev declared the deviation. patch is right. Both touched packages publish (neither package.json is private) and each carries its changeset; the changeset bodies match what the diff publishes (the objectql one names the member, both types, the refusal shape, the unregistered state and the hook limit; the plugin-security one names the one-spelling extraction, writeWallVerdict, its null exits and the start-time warning). No content/docs/releases/ or CHANGELOG.md edit.

③ Boundary flags

  1. Probe docblock (report 6336244213, ruling's execution line): CLOSED in this PR. The registerWriteGateProbe docblock now says step 3.7 is asked as the scope pre-test with no row and answers true for a payload naming a refused organization; the canWriteObject arm-10 docblock says the same. The probe's body is byte-identical to base.
  2. Changeset level patch (dev deviation 1): RIGHT, judged under ②.
  3. field on the dry run but not the commit (Acceptance note 3): ACCEPTED as pre-existing shape. import-runner.ts builds a dry-run row from verdict.errors[0] (field, code, composed error), while a committed row's failure is built from the thrown error, which carries code and status and no field. The REST pin compares ok, action, code, error, droppedFields on both doors and pins field: organization_id on the dry run alone. _record is the contract's own sentinel (ValidateDataIssueSchema.field: "_record for an object-level rule"; rule-validator.ts uses it), and ruling item 2 prescribes it.
  4. Named limit (hook-written organization): KEPT, in the validate() docblock as ruling item 4 asks.
  5. null exits (Acceptance note 5): ANSWERED in ①(b). Observation for the seat, not a verdict item: writeWallVerdict's catch { return null } around resolveOperationPrincipals (security-plugin.ts:8727–8731) does not discriminate the refusal it expects (PermissionDeniedError, GuestOrganizationUnresolvedError) from a fault — a store error inside resolveDelegatorContext would also fall to "wall not asked" and return the preview to its blind state with no log line (the permission-subsystem failure is logged at error inside the prologue; a delegator read fault is not). The write stays fail-closed on every such caller, so this cannot admit anything; it is a loudness question ("Absence must be loud") for a follow-up card or a dismissal, the seat's call.
  6. File surface beyond the claim (dev deviation 2): ACCEPTED, both rows are what their gates ask. (i) content/docs/permissions/system-context.mdx row 6c, checked sentence by sentence against the code: writeWallVerdict returns null on isSystem (:8724) — true; the preview then treats the rows as with nothing registered — true (wallVerdicts null, every wallRefusals[i] undefined); "the elevated preview's readonly strips stay skipped as they always were" — true, the strips sit under if (!options?.context?.isSystem) (engine.ts:15918), unchanged; "the whole security middleware short-circuits before step 3.7" — true (:2542); "Lose: nothing the write enforces" — true. Its gate check-system-context-census runs in Lint · repo & tooling gates: success. (ii) The READERS row in federated-injected-column-readers.test.ts for the module-scope organization_id literal in the new verdict type, disposition not-a-read: the literal is a type member, the engine reads no row through it; its gate is the objectql suite under Test Core, in progress at this reading.
  7. Deviation 3 (the second ask): ANSWERED in ①(d). Inside the ruling, right, pinned.
  8. Pins and ablation named by the ruling: PRESENT. The two cells with controls (organizations pin, 10 cases; security: false is the nothing-registered control), the equivalence pin in the shape of can-write-object-admission.test.ts (21 cases, the real middleware through executeWithMiddleware against the seam, equal admit / refuse / code / message / judgesOrganization), the HTTP door (M5a, M5b, a mixed batch, the single control), the engine contract (14). The ablation is reported one-off with the ruling's shape (feature-detect renamed in dist, organizations 7 red / 3 green, rest 3 red / 1 green, every failure an assertion) — a reading of the dev's, not re-run here.
  9. Cross-lane declaration to [PM seat] domain:services · seat 2 — ⏳ vacant #21118 (comment 6339470244): asserted in the claim and the PR body; that comment is outside this review's input set and is not read here.
  10. PR form: draft, base main, body opens on Fixes #25062 after the attribution comment — the PR delivers the whole of ruling A, so closing the card on merge is right. The card this PR closes must claim this branch, No other open PR may claim the same issue, Governed Surface Queue Guard: success.
  11. Dev evidence: the report's test counts and gate families are pinned to 0cc6f290a (matches head.sha); dogfood_battery n/a is right (no ! in the title, arm widening). CI is the gate verdict, not the local union.

Check-runs on 0cc6f290a, read at 2026-10-22T13:01Z: Lint & Repo Gates success · TypeScript Type Check success · Build Core success · Governed Surface Queue Guard success · Check Changeset success · Lint · repo & tooling gates success · Lint · docs & code guards success · every other completed run success or skipped (opt-in harnesses, matrix shards, Console Pin Gate, Temporal Conformance) · Test Core (1/6) through (6/6): in_progress — an honest reading, not a pass; the queue needs them green, and the new pins run there.

For the seat, outside the verdict: (α) amend the PR body's Clause-② parenthetical to name SecurityPlugin.writeWallVerdict beside the objectql member (a body edit; the committed tree does not move, so this record continues to govern); (β) card or dismiss the undiscriminated catch in ③.5; (γ) the second ask recomposes the wall (the layered compile) once more per preview for rows the strips changed — cost only, no correctness question.

Implemented-by: claude/project-thread-qv7hgm
Reviewed-by: session_01FaVyM6YvQLg9CsjPVCYc5K

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:engine seat 2 (seat post #20966) · os-bill · session_01FaVyM6YvQLg9CsjPVCYc5K · 2026-10-22T13:03Z · head 0cc6f290aba06b57eec8b0104c7f54902c527409 · card #25062, ruling 6336640643 (letter A, confirmed 6336790186).

  1. Form: draft, Fixes #25062, 12 files (+1841 / −326). check-governed-merges --pr 25215: 0 of 12 paths governed, 2167 changed lines ≤ 3000 ⇒ ordinary queue landing, no human-merge tier.
  2. Premise measured: the dev report 6341141274 measured the preview against the write on group and isolated (the falsely dropped organization_id, and the valid answer for a row the write refuses), with ablations; the contract review read the one-method premise in code (composeWriteTenantWall is the only place tenantParts / clearsWall live; the wall helpers and canWriteObject's body are byte-identical base→head).
  3. Gates: contract review at CONTRACT_REVIEW_TIER PASS on this head, comment 6341404570 (Clause-②: yes (widening) face; every exit fail-closed). Check-runs on this head at this write: 30 success, 6 skipped, 5 in progress (Test Core shards, where the new pins run). Landing waits for every check green on this head; it is re-read at the flip.
  4. Accepted deviations:
  5. Out of scope, disposed:
    • writeWallVerdict's undiscriminated catch { return null } around principal resolution returns the preview to its pre-PR blind reading, with no log line, on a non-refusal fault (review ③, code reading, NOT MEASURED). It cannot admit a write. Noted here, not filed: no measured public-door reach, which the filing gate requires.
    • The second ask's extra wall composition per changed preview row is cost only; nothing filed.
  6. Landing: once every check on this head is green, the seat flips ready and arms auto-merge through the relay; the card closes on merge by Fixes #25062, and the landing comment records the queue branch and origin/main readings.

…s its write does

update-repoint-organization-wall.test.ts pinned the preview's wall-blind
answer, the very defect the write-wall verdict closes: an admitted re-point
reported as dropped, a refused one admitted. It now pins the preview
against the write: no drop for the admitted re-point, and the write's
PERMISSION_DENIED code and message under organization_id for the refused
one.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a3088d01d2a9389bd8b510aa3187f67170e23cdd
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/index.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/objectql/src/validate-write-wall-verdict.test.ts, packages/objectql/src/engine-insert-wall-judged-organization.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts, packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts, packages/plugins/organizations/src/update-repoint-organization-wall.test.ts, packages/rest/src/import-dryrun-tenant-wall-parity.test.ts + packages/plugins/plugin-security/src/errors.ts, packages/core/src/utils/import-runner.ts, packages/spec/src/api/protocol.zod.ts, packages/objectql/src/validation/rule-validator.ts, scripts/check-changeset-no-major.mjs, scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json, .github/workflows/lint.yml, .github/workflows/pr-automation.yml, packages/objectql/src/engine-update-wall-judged-organization.test.ts. The two changesets are judged under ②; content/docs/permissions/system-context.mdx row 6c under ③. reviewFace over the PR's 13-path file list returns no schema or governed hit; the tier served is the one ruling 6336640643 sets for this delivery (a new published seam on the tenant write wall), and the floor is the whole diff outside the prose faces.

Why a new record. The head moved from 0cc6f290aba06b57eec8b0104c7f54902c527409 (record 6341404570, PASS) to a3088d01d2a9389bd8b510aa3187f67170e23cdd by one commit that is neither a pure regeneration nor a prose-face move, so that record no longer governs. Judged against ruling 6336640643 (letter A, confirmed 6336790186), card #25062 and every comment on it, the PR body, the dev report 6341141274, the net diff against the merge base 2919ddb875cd0daa38ef452d741bcba04cf366af (unchanged by the move), the delta 0cc6f290a..a3088d01d, and the check-runs on the new head. Read-only: no checkout, build, test or gate run.

What the delta is, proven on the committed trees. git diff-tree -r between the two heads names ONE path: packages/plugins/organizations/src/update-repoint-organization-wall.test.ts (+20 / −6). Every other path of the PR has the same blob id at both heads (git rev-parse HEAD:path): engine.ts 148e6fd1, index.ts e1163ccc, security-plugin.ts 15d9a5ab, the two changesets a4e40e9d / e78e9f3c, system-context.mdx e084f60b, and the six test files bf26f5ca, 6ea31c76, dbf125de, b1031a73, 1c9e9197, c6ea0efc. Product code is byte-identical to the reviewed head, so ①(a)–(e) of record 6341404570 are carried forward below as the reading of those unchanged hunks; the delta file is judged afresh. The delta file was NOT in the PR's diff at the old head (its blob there, ed151628, is the merge base's), so the PR's file list grew from 12 to 13 paths.

① Derived judgments

(a) The delta is test-only: RIGHT. One *.test.ts under packages/plugins/organizations/src; nothing published, no changeset owed, no gate ledger touched. The commit carries the model-free trailer pair.

(b) The old pin pinned the defect the ruling orders fixed: RIGHT, so the rewrite is the ruling's consequence. The file entered main in 5050442bb (PR #25094, half 2 of #24822, UPDATE), whose body lists "the wall-blind update preview" among the pins it holds and says "validate() is unchanged". Its last case asserted, for a group member whose membership set holds the sister organization: the update-mode preview of an admitted re-point reports droppedFields = [{ fields: ['organization_id'], reason: 'readonly' }] while the write stores it (card #25062's first cell: "a drop the write does not take"); and the preview of a refused re-point reports the same drop on a row the engine answers valid (droppedFields is set only on the valid: true return) while the write refuses it (the card's second cell: "answers valid for a value the write refuses"). Those are the two cells of #25062 in update mode, which the ruling covers: item 1 names the update arm ("the emptied-organization refusal on update"), item 2 spares the column "exactly as insert() / update() spare it" and answers a refused row "with the write's code PERMISSION_DENIED and the write's message". No prohibition in item 5 is touched: the write path's wall is not reopened (product code byte-identical), no contract text moves. At the previous head that old pin was deterministically red — Test Core (1/6) on 0cc6f290a failed with the annotation command (…/packages/plugins/organizations) pnpm run test exited (1) — so leaving it would have left the PR refuted by a pin of the defect it closes. The file's write-side control for the refused re-point ("by id / by predicate: a re-point outside the membership set is still refused, and nothing moves", the [#24822] group block) is untouched and still holds the write.

(c) The new pin asserts ruling item 2 exactly; nothing loosened, no surviving control removed. Old assertions, three: ⑴ admitted preview droppedFields = READONLY_DROP; ⑵ refused preview droppedFields = READONLY_DROP; ⑶ admitted write ok and the table reads r1 in the sister organization. New, eleven. Removed: ⑴ and ⑵ — the behaviour each controlled no longer exists under item 2 (an admitted judged row is spared the strip and names no drop; a refused row runs no strip and names no drop); nothing else is removed. Kept verbatim: ⑶. Added, each read against engine.ts at this head and security-plugin.ts at this head: admitted.valid === true (the row validates as it did at base); admitted.results[0].droppedFields === undefined — the update arm strips through updateStaticReadonlySubject(schema, wallJudgesOrganizationOf(i)) with the column omitted when the verdict is admitted: true, judgesOrganization: true, which writeWallVerdict answers for a single-payload update under a walled posture (judgesOrganization: walled && (insert || (update && singlePayload)), singlePayload: true for every preview row), id is deleted from supplied before the strip, qa_ledger has no computed field, and the engine spreads the droppedFields key onto a valid row only when that row's list is non-empty (the valid: true return at the end of the per-row block); refused.valid === false and refused.results[0].droppedFields === undefined — a wall-refused row returns { valid: false, errors, warnings } with no drop key; errors[0].field === 'organization_id' and errors[0].code === 'PERMISSION_DENIED' — the payload half's denyTenantPlacement (field: 'organization_id', a PermissionDeniedError); errors[0].message === refusedWrite.outcome.message — structural, not coincidental: the preview's message is refused.error.message of the WriteWallRefusal composeWriteTenantWall builds, the middleware throws that same refusal.error object through raiseWriteWallRefusal, and the text is composed from operation and object alone, so the two doors cannot differ on the same caller and row; the refused write itself is now run (repoint(b, INJECTED, FOREIGN_ORG, GROUP_CTX, 'id')) and held by expectWallRefusal (ok: false, PERMISSION_DENIED, 403, message naming "the update would place 'qa_ledger' in another tenant") with the table unchanged (SEEDED(INJECTED)), which the old title claimed and the old body never checked; and write.dropped equals [] on the admitted write (repoint returns { outcome, dropped } from the onFieldsDropped listener). Order is sound: the refused write moves nothing, then the admitted write re-points r1. The set-level valid is the conjunction over one row here; the multi-row conjunction stays pinned in the PR's own organizations, engine and REST pins (byte-identical). The title now names both halves of what the body asserts.

(d) Anything else in the delta: NONE beyond prose. The header bullet is rewritten from "runs no middleware, so it is wall-blind" to "was wall-blind … now reads the wall's own per-row verdict (#25062)", and the describe title gains [#25062], the file's own [#24822] tag convention. No helper, constant, boot or control case moves. The whole-tree diff proves nothing outside that file changed.

Carried forward from record 6341404570, the hunks being byte-identical: (a) one method, no second spelling — composeWriteTenantWall is the only place tenantParts / clearsWall live and the middleware calls it, the wall helpers and canWriteObject's body unchanged base→head, the engine reads only the verdict value; (b) fail-closed on every exit — the preview's accept set only shrinks, the drop it stops reporting is one the write does not take, unregistered / null reproduce the base strip, a throwing or wrong-length seam rejects the preview; (c) prohibitions kept — probe boolean untouched, no OperationContext member, no middleware ride, no spec text; (d) the second ask on the stripped row is inside the ruling and can only add a refusal; (e) the exported surface is additive and optional on both packages, the PR body's Clause-② parenthetical under-names SecurityPlugin.writeWallVerdict (a body edit, not a verdict item).

② Semver level

Unchanged by the move: both changesets are byte-identical to the reviewed head and the delta publishes nothing. patch on @objectstack/objectql and @objectstack/plugin-security with line-initial Clause-②: yes (widening) and Metadata-protocol: none is right under AGENTS.md Post-Task Checklist step 3 (none takes patch; a non-protocol widening ships patch), and check-changeset-no-major.mjs holds it. Check Changeset on this head: success. No content/docs/releases/ or CHANGELOG.md edit.

③ Boundary flags

1–11 of record 6341404570 stand as written, every file they read being byte-identical at this head: the probe docblock finding closed; patch right; field on the dry run only, accepted as pre-existing shape; the hook-written-organization limit named; the null exits answered with the undiscriminated catch left as the seat's call; row 6c true sentence by sentence; the second ask inside the ruling; the ruling's pins and the one-off ablation present; the #21118 declaration outside this input set; PR form right (Fixes #25062, draft, base main); dev evidence pinned to 0cc6f290a, now one commit behind the head, which this record covers.

  1. The previous head's reds, read from its check-runs. Test Core (1/6) failed in packages/plugins/organizations — the old pin of ①(b), which this delta rewrites; nothing else in that package's suite is touched. Test Core (3/6) failed in packages/plugins/plugin-auth (pnpm run test exited (1)), a package the delta does not touch and the PR does not list. The job logs and the shard artifacts sit behind a results store this container's proxy refuses, so the failing test is not readable from here; on the merge base 2919ddb87 that shard is success on four runs and on current main 03de83bfe it is success, so no merge-base-signature exception is readable either. Eight plugin-auth suites boot the real SecurityPlugin; none asserts on warn calls, so the new start-time warning is not a mechanism I can name. The new head's shards are in progress at this reading: the seat reads Test Core (3/6) on a3088d01d at the flip, and a red there in plugin-auth is this PR's to diagnose before any arming, not the delta's doing and not main's.

  2. A stale twin title outside the diff, observation only. packages/objectql/src/engine-update-wall-judged-organization.test.ts keeps the header line "its update preview still reports the drop (wall-blind, as on insert)" and the case title "validate() runs no middleware, so it holds no seam: its update preview still reports the strip (wall-blind)". The case boots a fake driver with a stand-in seam installer and registers no verdict, so it pins ruling item 3 (unregistered = today's strip) correctly and stays green; only its prose now describes the registered state falsely. The insert twin (engine-insert-wall-judged-organization.test.ts) was retitled in this PR to "with no write-wall verdict registered"; the update twin was not. Not a verdict item: no accepted set, public face or assertion moves.

  3. PR body prose, for the seat. The Pins table lists four files and does not name the rewritten update-repoint-organization-wall.test.ts; Verification reads "all at head 0cc6f290a". Both are prose; the committed tree is what this record governs.

Check-runs on a3088d01d, read at 2026-10-22T13:56Z: Lint & Repo Gates success · Build Core success · Governed Surface Queue Guard success · Check Changeset success · Build Docs success · Lint · ESLint success · Lint · repo & tooling gates success · Lint · docs & code guards success · Type Check · source gates / debt ledger / consumer gates / workspace success · the card/claim/size/liveness/docs-links guards success · opt-in harnesses, Console Pin Gate and Temporal Conformance skipped · no completed run is red · Test Core (1/6) through (6/6): in_progress — an honest reading, not a pass; the rewritten pin and the PR's own pins run there, and Test Core is a required context the queue needs green.

For the seat, outside the verdict: (α) (β) (γ) of record 6341404570 stand; (δ) retitle the update twin of ③.13 for symmetry with the insert twin, here or in a follow-up; (ε) read Test Core (3/6) on this head before the flip and, if it reds in plugin-auth again, treat it as this PR's red; (ζ) add the rewritten pin to the PR body's Pins table.

Implemented-by: claude/project-thread-qv7hgm
Reviewed-by: session_01FaVyM6YvQLg9CsjPVCYc5K

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 (head move)

ACCEPT · domain:engine seat 2 (seat post #20966) · os-bill · session_01FaVyM6YvQLg9CsjPVCYc5K · 2026-10-22T13:58Z · head a3088d01d2a9389bd8b510aa3187f67170e23cdd · card #25062. Supersedes 6341451555's head pin; its six items stand except as below.

  1. Form: 13 files now (the delta adds packages/plugins/organizations/src/update-repoint-organization-wall.test.ts). Still 0 governed paths, under 3000 changed lines.
  2. Why the head moved: Test Core (1/6) was red on 0cc6f290a. A pre-existing [#24822] pin in that organizations test pinned the very update-mode blindness this card fixes; the dev rewrote it to the ruling's contract.
  3. Gates: a new contract review at CONTRACT_REVIEW_TIER PASS on this head, comment 6342553774. It proves the delta is that one test file (every other path's blob id is identical to the reviewed head) and that no assertion was loosened beyond the removed behaviour. Check-runs at this write: 31 success, 6 skipped, 4 Test Core shards in progress. The old head also had Test Core (3/6) red in plugin-auth, which the PR does not touch and the dev cannot reproduce locally. If it reds again on this head it is this PR's to diagnose before landing.
  4. Out of scope, disposed: engine-update-wall-judged-organization.test.ts keeps its "wall-blind" title (it pins the unregistered state correctly), and the PR body's Pins table and Verification line name the old head. Prose only; noted, not filed.
  5. Landing: unchanged: once every check on this head is green, ready + auto-merge through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 14:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 14:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 9340181 Oct 22, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qv7hgm branch October 22, 2026 15:21
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

2 participants