Skip to content

fix(objectql,plugin-security): an update's organization_id re-point the Layer 0 wall admits is stored as sent - #25094

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24822-update-repoint-honoured
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24822-update-repoint-honoured

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24822

Clause-②: yes (widening) — an update re-point of organization_id the Layer 0 wall admits is now stored instead of silently dropped, and an in-process strictReadonlyWrites: true update naming an organization the wall admits is now stored instead of refused ERR_READONLY_FIELD_REJECTED.

This is half 2 of the card: UPDATE. Half 1 (insert) landed as PR #25048 (2b1382a5b3). This half reuses its member, PostHookWriteImageCheck.judgesOrganization, and its F4 provenance rule, through one helper shared by both write verbs. The card's last half, so this PR closes it.

What was wrong

Under group, take a member whose active organization is A and whose membership set includes C. It updates a row of an app object, which carries the platform-injected tenant column, naming organization_id: C.

  • Answer: 2xx, and the row stayed in A, by id and by predicate alike.
  • Mechanism:
    • The Layer 0 write wall (plugin-security step 3.7) judged C and admitted it: on the payload as sent, and on the stored image (by id the prior row merged with the final payload; by predicate every matched row merged with it).
    • The engine's static-readonly update strip then took the value, because TENANT_SCOPE_FIELD_DEF declares readonly: true, and the row kept its own organization.
  • Visibility: a droppedFields readonly entry and a WARN; beforeUpdate hooks were not shown the value.
  • The inconsistent half. An object that declares its own writable organization_id stored the same re-point, by id and by predicate (premise U2, measured below), so honouring it on the injected column is a fix, not a new capability.

The governing text is the organizations runtime's Middleware A comment: a supplied value "goes on to the Layer 0 write wall … the same wall an UPDATE that re-points organization_id meets, and gets the same answer: admitted where the caller's organization scope … admits it, otherwise refused 403 PERMISSION_DENIED". The seat call 6305891812 carried the UPDATE half on this card.

What changed

  • packages/objectql/src/engine.ts.
    • staticReadonlySparesOrganization(schema, wallJudgesOrganization), new: the ONE answer both write verbs read. It spares the column only when the wall's seam declares the member, the object is one the INSERT strip judges (staticReadonlyInsertSubject holds the column: never sys_, never a platform-internal managedBy bucket), and the column's provenance is not 'author' (F4).
    • insertStaticReadonlySubject now reads it. Its behaviour is unchanged (half 1's pins are green, and ablation B1-F4 turns its F4 pin red through the shared helper).
    • updateStaticReadonlySubject(schema, wallJudgesOrganization), new: the update strip's schema view, without organization_id where the helper spares it; schema itself otherwise.
    • In update(), wallJudgesOrganization is read ONCE, inside the middleware chain's final handler (!isSystem and the seam's member === true), and readonlyUpdateSubject is built once from it. Every static-readonly pass reads that one view: the pre-hook hide pass (what beforeUpdate is shown), the by-id strip and the predicate strip (what is stored).
    • staticReadonlyStoredView / staticReadonlyStripTakes (the views the readonlyWhen strips and the master-detail settlement read) take the same flag on their descriptor and apply the same helper, so a readonlyWhen predicate reading record.organization_id reads the organization the row is stored in.
    • The PostHookWriteImageCheck.judgesOrganization doc now covers updates. No member is added.
    • validate() is unchanged.
  • packages/plugins/plugin-security/src/security-plugin.ts.
    • Step 3.7's setter: the operation === 'insert' conjunct is replaced by operation === 'insert' || (operation === 'update' && !Array.isArray(data)), still ANDed with tenantParts.length > 0. The array clause restates the enclosing condition on purpose: the member is the seam's own claim about what it judges.
    • The private WriteImageCheckSeam doc, the setter's comment, and step 3.7's "effectively immutable" paragraph are rewritten to state the stored re-point (seat ruling 6).
  • Tests:
    • objectql: engine-update-wall-judged-organization.test.ts (new, stand-in installer), and census rows in federated-injected-column-readers.test.ts for the shared helper.
    • plugin-security: update-seam-judges-organization.test.ts (new, real ObjectQL + SqlDriver + SecurityPlugin, real grant tables); in insert-seam-judges-organization.test.ts the cell "an update's seam does not carry the member" flips to the update half's answer; tenant-wall-organization-removal.test.ts header prose only.
    • organizations: update-repoint-organization-wall.test.ts (new, the real stack half 1 measured on).
  • Changeset: .changeset/24822-update-repoint-honoured.md: @objectstack/objectql minor, @objectstack/plugin-security patch.

Public surface

Every change below is a widening. None admits a (caller, organization) pair the wall does not already admit, and the engine's own gates (the wall, the strip, the strict flag) refuse nothing they accepted before.

  • PostHookWriteImageCheck.judgesOrganization (published, OperationContext.postHookWriteImageCheck): its contract now covers single-payload updates, and ObjectQL.update reads it. No new member.
  • A walled update's admitted re-point is stored as sent, by id and by predicate.
  • It is no longer reported as dropped: not in droppedFields, not through onFieldsDropped, not by the read-only WARN.
  • An in-process strictReadonlyWrites: true update naming an admitted organization is stored, where it used to be refused ERR_READONLY_FIELD_REJECTED.
  • beforeUpdate hooks see the caller's organization_id on a walled update.
  • Under group, a member with NO active organization re-points within its membership set the same way (before: taken and reported).
  • An on-behalf-of update by a crossing operator (ADR-0090 D10) is held to the delegator's wall: stored as sent where it admits the re-point (before: taken), refused where it does not.
  • N2, stated: outcomes that depend on the stored organization now follow the admitted value. An organization-scoped unique key, a validation rule, a readonlyWhen / requiredWhen predicate and a hook reading organization_id see the organization the row moves to. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, exactly as on a declared-column object today; before, the re-point was dropped and the write reported success.
  • Unchanged, by ruling or by measurement: sys_ and platform-internal objects, an AUTHORED readonly: true organization_id, an emptied value, an array payload, every no-wall path, preserveAudit, isSystem, and the wall-blind update preview.

Seam

  • Who sets the member on an update, and when. Only step 3.7, on the seam it composes for a single-payload update, and only with the wall's own parts in it (tenantParts non-empty: the caller's Layer 0 and the ADR-0090 D10 delegator's). That seam's loop then holds every stored image's organization to the wall and refuses an emptied one; the payload half of the same step has already refused every supplied value outside the wall, and every emptied one, before the engine runs.
  • Left unset: step 3.6's business-check-only update seam (how single gets a seam with no wall); a crossing platform operator's update (it gets no step 3.7 seam: tenantParts is empty and the no-active-organization arm is insert-only); an array update payload (step 3.7 composes nothing for it); isSystem (the middleware returns before steps 3.6 and 3.7).
  • What the engine reads. The member, once, and nothing else. It never derives the answer from the posture or the context: that would be a mirror of the wall, wider than the wall wherever the wall is not mounted (half 1's measured P5). tenantLayer0Verdict is untouched.

Boundary statement

The set of (caller, organization) pairs an update can move a row into is the set the Layer 0 wall admits, before and after. A, C and D are organizations: A is the active one and the row's own, C is in the membership set, D is outside it.

  • Stacks. Rows marked (org) are ObjectQL + SqliteWasmDriver + the real OrganizationsPlugin + the real SecurityPlugin, the stack half 1 used. Rows marked (sec) are ObjectQL + SqlDriver + SecurityPlugin with the real grant tables and a delegator rebuilt from stored sys_user, sys_member and sys_user_permission_set rows.
  • Before is measured twice: by a premise probe on base 2b1382a5b, and by the final pins with main's two source files (c67f244f5) restored over the tree (tree only; restored to blob == HEAD after). After is the probe at 22e257dfe and the pins at the final head.
posture · caller update on the injected column before after
group member (org) re-point A→C, by id 2xx, row stays A, droppedFields readonly + WARN, hook sees none 2xx, row moves to C, nothing reported, hook sees C
group member (org) re-point A→C, by predicate 2xx, the matched row stays A, reported + WARN 2xx, the matched row moves to C, nothing reported
group member (org) re-point to D, by id / by predicate 403 PERMISSION_DENIED 403 PERMISSION_DENIED
group member (org) names A (its own), by id stays A, reported + WARN stays A, nothing reported
group member, strictReadonlyWrites (org, in process) re-point to C, by id / by predicate ERR_READONLY_FIELD_REJECTED stored C
group member with NO active organization (org) re-point to C / to D C: stays A, reported; D: 403 C: stored C; D: 403
isolated member (org) names A, by id / by predicate stays A, reported + WARN stays A, nothing reported
isolated member (org) re-point to C, by id / by predicate 403 403
group / isolated member (org) emptied (null, ''), by id / by predicate 403 (#22278) 403 (#22278), nothing stored
group member (org, sec) ARRAY payload refused, no step 3.7 seam refused, no step 3.7 seam
group crossing operator for a delegator holding {A, C} (sec) re-point to C, by id / by predicate stays A, reported stored C
same (sec) re-point to D 403 (the delegator's wall) 403 (the delegator's wall)
isolated crossing operator for a delegator (sec) names A / re-point to C A: stays A, reported; C: 403 A: nothing reported; C: 403
group member, AUTHORED readonly: true organization_id (org, F4) re-point to C, by id / by predicate stays A, reported + WARN; strict refuses unchanged (the seam now declares the member; the author's declaration keeps the strip)
group member, sys_ object on the injected column (sec) re-point to C, by id / by predicate stays A, reported; strict refuses unchanged (the seam declares the member; the engine keeps the strip)
group member, a managedBy: 'system-data' app object (sec probe) re-point to C stays A, reported stored C (an app object the insert strip judges, as on insert)
  • What the wall admits is unchanged: group {A, C}, isolated {A}, a delegated update the delegator's own set. B2-WALL shows the refusals are the wall's own.
  • Through an explicit re-point on the injected column of an app object: group goes from {A} to {A, C}, the wall's set; isolated stays {A}.
  • Through a declared-column object, already {A, C} under group before this change (U2, the control cell in the organizations file).
  • No pair becomes writable that the wall does not admit.

Unchanged before and after, measured at base and pinned at the head; on each the re-point is taken, reported and refused under strict, or refused earlier, as before:

path re-point stored seam
single, no organizations runtime, by id / by predicate C A, reported none
single with a business update check, by id / by predicate C A, reported installed, member unset
single, strictReadonlyWrites C refused ERR_READONLY_FIELD_REJECTED none
group / isolated, security plugin absent, by id / by predicate C A, reported none
platform operator crossing on a private object, group / isolated, by id / by predicate D A, reported none
tenancy.enabled: false object, authored read-only organization_id C A, reported none
single, preserveAudit: true C A, reported none
system context, by id / by predicate D as sent none
sys_user_position / sys_user_permission_set, a member C refused 403 by the RBAC administration gate (ADR-0090 D12) none
sys_user_position / sys_user_permission_set, a platform administrator (walled there) C (grantee a member of C) / D C: stored as sent (the column is declared WRITABLE; the strip never took it); D: 403 installed
managedBy: 'engine-owned' / 'append-only' object (sec probe; the engine-level cell is pinned in oql) C refused 403 by the ADR-0103 engine-owned guard none
a by-id update of a row outside the scope (org, group) C refused 404 RECORD_NOT_FOUND (step 2.7, #21771) —
a falsy payload id beside where.id (org, group) C refused 403 (step 2.7) —

Must-not-regress

Each point of both contract reviews' ③ lists for half 2, with its pin and the ablation that turns that pin red (the Ablations table below has the counts).

From 6308267963 ③:

  1. The setter stays on non-empty tenantParts; never on step 3.6's check seam or the crossing operator's seam; the insert conjunct replaced deliberately. The setter ANDs tenantParts.length > 0 with insert || (update && !Array.isArray(data)). Pins: sec "single with a business check: … UNSET", org "single with a business update check, by id / by predicate" (B2-SET1b red); sec and org operator-crossing cells, NO SEAM; the positive control B2-SETOFF.
  2. No wall seam for an ARRAY update payload, so no spare there. Pins: org and sec array cells (B2-ARR1 red). Removing the setter's own array clause alone is an equivalent mutant (B2-ARR2, all green): the enclosing condition never reaches the composition for an array update.
  3. security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278 on both halves, and the payload-half refusal of D. Pins: the org [#22278] block, eight cells (B2-E22278 red); the org D cells and sec D cells (B2-WALL red). tenant-wall-organization-removal.test.ts stays green in the full suite.
  4. One read for the by-id and predicate seams, so the hook view and the stored row agree; isSystem and update-mode validate() unchanged; the census row. One wallJudgesOrganization read, one readonlyUpdateSubject. Pins: org "shown to beforeUpdate" by id / by predicate and oql "SET, by id" (B1-HIDE red); oql readonlyWhen cell (B1-VIEW red); system-context cells in all three files; the preview cells in org and oql; census rows. The !isSystem conjunct is an equivalent mutant (the strips never run for a system caller).
  5. The honoured fail-closed guard. Untouched; its pin tenant-wall-post-hook-image.test.ts "a host that never runs the installed wall judgement …", both drivers (B2-HON red).
  6. Step 3.7's "effectively immutable" comment. Rewritten.

From 6309495055 ③:

  1. Where the member is set: only where step 3.7 composes the wall's parts into the by-id or predicate seam; never step 3.6's update seam, an array payload, or isSystem. As points 1, 2 and 4 above; sec "a system context installs no seam".
  2. security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278: emptied refused, no stamp refills it; F3 does not carry across. The org [#22278] block (B2-E22278 red: with the gate off, the spare stores the emptied value).
  3. F4: an authored readonly: true organization_id keeps the update strip. Org F4 by id / by predicate, oql "SET, but an AUTHORED …" (B1-F4 red, which also turns half 1's insert F4 pins red through the shared helper).
  4. sys_ objects: measure and rule. Measured as premise U3, ruled by the seat (ruling 1: the update spare covers exactly the objects the insert spare covers). Pins: oql sys_ and engine-owned cells, sec sys_ cell (B1-SYS red). The grant tables declare a WRITABLE organization_id, so the strip never took a re-point there: their cells are controls, measured identical before and after (the before run with main's sources passes them), and no mutation of the spare can reach them.
  5. The existing gates: the by-id falsy-id refusal (security: a by-id UPDATE's row-level check is judged on the pre-hook change set, so a beforeUpdate stamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989), the step 2.7 pre-image selection, the predicate seam's judgement of every matched row. Org falsy-id cell (B2-PRE27 red); org out-of-scope row cell (B2-PRE27P red: it needs step 2.7 AND the engine's scoped prior read off, two independent gates); oql "SET, by predicate … the seam judges every one" (B1-PRED red).
  6. preserveAudit's update-only exemption must not widen to organization_id. Org preserveAudit cell, oql "preserveAudit does not spare it" x3 (B1-PA2 red).
  7. One helper, and the preview. staticReadonlySparesOrganization is shared by insert and update. The preview: see Acceptance notes.

Pins

  • organizations/src/update-repoint-organization-wall.test.ts (real stack; an innermost probe middleware records each update's seam and the organizations it judged): every row of both tables above that is marked (org), as group / isolated, by id and by predicate.
  • plugin-security/src/update-seam-judges-organization.test.ts: the member SET on a walled update (isolated, group by id and by predicate, composed with a business check); UNSET on single's check seam; NO SEAM for a crossing operator, a system context and an array payload; the sys_ scope (a sys_ object, both grant tables); the delegated update under group and isolated.
  • objectql/src/engine-update-wall-judged-organization.test.ts (stand-in installer): with the member SET, by id and by predicate, the re-point is stored, shown to beforeUpdate, handed to the seam on every stored image, not reported, not refused under strict; created_by is still taken; an AUTHORED column, a sys_ object and an engine-owned bucket keep the strip; a readonlyWhen lock reads the stored organization. With the member absent, false, or no seam: taken, withheld, reported, WARNed, refused under strict, and preserveAudit does not spare it. A system context is unchanged. The update preview still reports the drop.

Ablations

Every leg went through node scripts/ablation-replace.mjs (WRAP mode) inside a script carrying its own trap … EXIT INT TERM restore against the absolute path, with the restore proven by blob == HEAD and an empty git diff HEAD. Each ran the six pin files (org 88, sec 28, oql 42 cases), and each predicted direction was written down before the run. objectql, plugin-security and driver-sqlite-wasm / driver-sql are vitest source aliases in these suites, so there is no dist/ leg.

id mutation predicted red observed (org / sec / oql failed)
B1-A4u the update spares without the member (flag = !isSystem) every no-wall update cell 14 / 2 / 12
B1-A6u the update ignores the member (positive control) every stored-as-sent update cell 9 / 4 / 5
B1-SYS the spare ignores the insert subject's sys_ / bucket exclusions the sys_ and engine-owned cells only 0 / 1 / 2
B1-F4 the provenance narrowing off the F4 cells only, insert and update 3 / 0 / 2
B1-VIEW the stored view loses the flag the readonlyWhen cell only 0 / 0 / 1
B1-HIDE the hide pass reads the whole schema the "shown to beforeUpdate" cells only 2 / 0 / 1
B1-PRED the predicate seam judges only the first matched row the every-matched-row cell only 0 / 0 / 1
B1-PA2 preserveAudit's exemption widened to organization_id the preserveAudit cells 1 / 0 / 4 (the 4th is the census test, which sees the mutation's own 'organization_id' literal in rule-validator.ts)
B2-SET1b step 3.6's update check seam declares the member the single + check cells 2 / 1 / 0
B2-SETOFF step 3.7's update arm off (insert only; positive control) every stored-as-sent update cell and every update sighting 11 / 7 / 0
B2-ARR1 step 3.7 composes for an array update too the array cells (sighting) 1 / 1 / 0
B2-ARR2 the setter's own array clause removed alone none: equivalent mutant 0 / 0 / 0
B2-E22278 emptiesOrganization always false the eight [#22278] cells 8 / 0 / 0
B2-WALL clearsWall admits every row every refusal outside the scope, insert and update 16 / 7 / 0
B2-PRE27 step 2.7's pre-image check skipped the falsy-id cell (and the out-of-scope cell, unless another gate holds it) 1 / 0 / 0: only the falsy-id cell; the engine's own scoped prior read still answered 404
B2-PRE27P B2-PRE27 plus the engine's prior read unscoped the falsy-id and out-of-scope cells 2 / 0 / 0
B2-HON the honoured fail-closed guard off (run on tenant-wall-post-hook-image.test.ts + the sec update file) the fail-closed cell, both drivers sec 2 / 48

Two first attempts were no-ops that ablation-replace refused before running anything (exit 1, "the anchor count moved 1 -> 1"): B1-PA and B2-SET1, whose replacements still contained their anchors. Their re-runs are B1-PA2 and B2-SET1b.

Tests and gates (final head 784b1f9f3)

Acceptance notes

  • The update-mode validate() preview is wall-blind (seat ruling 7). It runs no middleware, so it holds no seam. After this change, a group update preview naming C still reports droppedFields: organization_id readonly while the write stores C; a preview naming D answers the same drop while the write answers 403 (before and after). Pinned in the org and oql files. objectql: the validate preview (import dry run) runs no tenant write wall, so after PR #25048 it reports organization_id as dropped for a walled insert that the write stores, and it answers valid for a value the write refuses #25062 carries the preview, insert and update alike; this PR does not change it.
  • sys_ objects on the injected column keep the update strip, by seat ruling 1. A source scan of this tree lists the sys_ objects that declare no organization_id of their own, and so carry the injected one wherever they are tenant-scoped: sys_attachment, sys_comment, sys_comment_reaction, sys_business_unit_member (platform), sys_email_template, sys_webhook (config), sys_user_preference, sys_notification_template, sys_notification_preference (system-data), sys_file, sys_upload_session, sys_inbox_message. Where the wall applies to one of them, a re-point is still taken and reported, exactly as an insert's organization there is (pinned on a sys_ object in the sec file); the seam declares the member there without effect.
  • An array update payload beside where.id reaches the driver and fails with a raw SQL error (no such column: 0), before and after this change; measured in process only, no public door measured. Observation, not filed (carrier: none).
  • N1 (half 1's review), not in this claim: the insert-side comments in step 3.7's ADR-0123 D2 arm ("the column is read-only to a session payload") and the engine's insert post-image comment, and ADR-0123 amendment item 1, which is the maintainer's pointer.

Generated by Claude Code

…he Layer 0 wall judges is stored as sent

Step 3.7 now declares judgesOrganization on the stored-image seam it
composes for a single-payload update as well as an insert (tenantParts
non-empty). The engine reads that member once per update and hands it to
every static-readonly pass (the pre-hook hide pass, the by-id and
predicate strips, and the stored view the readonlyWhen strips and the
master-detail settlement read), through one helper shared with the
insert side. Only the platform-injected column on an object the insert
strip judges is spared, so sys_ and platform-internal objects and an
authored read-only organization_id keep the strip.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
… the Layer 0 wall judges, and the changeset

The engine half with a stand-in installer, step 3.7's setter on real
ObjectQL + SqlDriver + SecurityPlugin (sys_ scope and delegated
update included), and the boundary matrix on the real organizations
stack: group and isolated, by id and by predicate, admitted and
refused, emptied, array payload, F4, strict, preserveAudit, the
no-wall paths and the wall-blind update preview. The insert-seam
file's update cell flips to the update half's answer. Census rows
follow the shared spare helper.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
…tep 3.7 as it now reads

Step 3.7 no longer calls an update's organization_id effectively
immutable: a re-point the Layer 0 wall admits is stored. The header's
citation and its note on the injected column follow it. Comment only.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 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 4feccc26851b89dcb8f4d78dfab011e73d12c128 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 4feccc26851b89dcb8f4d78dfab011e73d12c128

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 784b1f9f3110eeb99ec43838259df08047bd6dec
Local-runs: none

Inputs read. Card #24822: its body and all 14 comments, through the half-2 dev report 6312414095, among them the widened claim 6305515833, the seat call 6305891812 and the landing note 6310089797. The binding checklist: half 1's reviews 6308267963 ③ and 6309495055 ③ on PR #25048. PR #25094: its body, its 9-file list, and the net diff against merge base f46ef4d70, the PR's second main merge. The code the hunks cite, read at the head:

  • engine.ts: update(), the static strips and settleMasterDetailLanding;
  • rule-validator.ts: stripReadonlyFields, staticReadonlyInsertSubject, isPreservableUnderAudit;
  • security-plugin.ts: steps 3.6 and 3.7, and the honoured guard;
  • the two grant tables' object files, TENANT_SCOPE_FIELD_DEF, matchesFilterCondition, and driver-sql's update.

Nothing was built, tested or re-run. The only script run was the record tool (record-recognisers --template, also with --paths). The check-runs were read last. Rendered 2026-10-21T09:46Z.

① Derived judgments

(a) RIGHT: the (caller, organization) pairs an update can move a row into equal the Layer 0 wall's admitted set, before and after, on every posture, by id and by predicate.

(b) RIGHT: judgesOrganization is set only on step 3.7's composed seam, for an insert or a non-array update, with tenantParts non-empty.

  • One assignment outside tests (security-plugin.ts :4369–:4372; git grep at the head). The engine only reads it (:16146 for insert, :17642 for update).
  • Never set on:
    • step 3.6's newWriteImageCheck() seams (:3826, installed at :3839 and :3909). They carry no member, and the composition wraps them in a new object without mutating them;
    • the crossing operator's seam, where tenantParts is empty and the spread is {};
    • an array update. The composition is skipped, and the spread restates the condition, so B2-ARR2 is an equivalent mutant, as the body says;
    • isSystem, which never reaches step 3.6 or 3.7.
  • The operation === 'insert' conjunct was replaced deliberately, not deleted (half 1's ③1). tenantParts is not reassigned after the composition.
  • sys_ seams. The member IS now set on a walled sys_ update seam. The engine's spare ignores it there through the insert-subject condition, so it has no effect (pinned: the sec sys_ cell; B1-SYS).

(c) RIGHT: the shared helper, and the descriptor member that travels through settleMasterDetailLanding.

  • staticReadonlySparesOrganization (:2453) is half 1's insertStaticReadonlySubject body factored out: the flag, the insert subject holding the column, and the provenance not 'author'. insertStaticReadonlySubject (:2469) behaves identically, because staticReadonlyInsertSubject is pure. B1-F4 turns half 1's insert F4 pins red through the shared helper.

  • updateStaticReadonlySubject (:2495) returns the schema object itself when it does not spare, so every unspared update is byte-identical. Otherwise it returns {name, fields} without the column. stripReadonlyFields reads only fields and name (rule-validator.ts :2117), so the reduced view changes the column and nothing else.

  • One read, one view (:17641–:17643), feeding:

    • the hide pass (:17652);
    • the by-id strip (:18489);
    • the predicate strip (:18778);
    • through the descriptor, staticReadonlyStoredView (:4283).

    No other static-readonly update pass reads the column off updateSchema. validate() is untouched, so its preview stays wall-blind, as stated.

  • settleMasterDetailLanding (:10334, not edited): right.

    • It destructures staticStrip and passes that same object to staticReadonlyStripTakes (:10359), so the member travels at runtime.
    • It is behaviour-neutral there. That call asks only about the master-detail FK, and the flag moves only organization_id. The platform's injected column is a lookup, never the master-detail FK, and an authored one has 'author' provenance and is never spared.
    • The judgeFkLock closures read the update's own descriptor.
    • N-c below is a typing nit on this point.

(d) RIGHT: the pins cover the ruled matrix, and the negative pins are ablation-verified per the PR body.

(e) RIGHT: every public-surface change is listed in the changeset and the PR body, N2 included.

  • Listed:
    • the published member's contract now covers single-payload updates (no new member; reached through OperationContext);
    • the re-point stored as sent, by id and by predicate;
    • no droppedFields, no onFieldsDropped, no WARN;
    • strict stores;
    • beforeUpdate sees the value;
    • no active organization;
    • on-behalf-of;
    • N2: an organization-scoped unique key, a rule, readonlyWhen / requiredWhen, and hooks;
    • the unchanged list.
  • Precision notes, not missing surface:
    • P1. In process only, an organization_id: undefined on a walled update was taken, reported and refused under strict. It is now kept, not reported and not refused, and the stored value is unchanged (knex omits it). There is no REST reach, because JSON carries no undefined. Half 1 listed its analogue (F3); this one is not listed.
    • P2. afterUpdate, data.record.updated and every event consumer now see the row in its new organization. The changeset's "outcomes that read the stored organization" sentence covers this, but does not name them.

② Semver level

  • @objectstack/objectql minor: RIGHT. No member is added. But the published member's contract widens to updates, and an in-process strict update naming an admitted organization is now stored. Clause-②: yes owes at least minor, as half 1 did.
  • @objectstack/plugin-security patch: RIGHT. The changes are a private type's doc and the plugin's own seam composition.
  • @objectstack/organizations: tests only, so no changeset is owed. All three packages are in the fixed group, and none is private.
  • Version skew is inert both ways. An older engine ignores the member on an update; an older plugin never sets it there.
  • The Clause-② line: RIGHT. yes (widening) stands at line start in the PR body and the changeset, as the same sentence. The engine's own gates refuse nothing they accepted before. N2's stored-state refusals (for example, an organization-scoped unique key in the new organization) are the ones a declared-column object already meets, so half 1's reading of N2 (not a narrowing) holds unchanged. Check Changeset is green.
  • Tier: RIGHT. Read on the 8 source paths, record-recognisers --paths decides no schema or rules face. The changeset's Clause-② line then decides the declaration face, served at TIER_DEFAULT.

③ Boundary flags

The seat's rulings for half 2:

  1. The spare covers exactly the objects the insert spare covers: RIGHT. It is the same helper; sys_ and the platform-internal buckets are excluded (B1-SYS).
  2. security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278, an emptied organization stays refused: RIGHT. Both halves are untouched, with 8 pins and B2-E22278.
  3. No spare on an array update payload: RIGHT. The composition is skipped and the spread restates the condition (B2-ARR1).
  4. The gates are kept (security: a by-id UPDATE's row-level check is judged on the pre-hook change set, so a beforeUpdate stamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989, step 2.7, every matched row, honoured): RIGHT. None is edited (B2-PRE27 and B2-PRE27P, B1-PRED, B2-HON).
  5. preserveAudit does not widen: RIGHT. isPreservableUnderAudit is untouched, and the injected column is system: true, so it is not preservable (B1-PA2).
  6. The "effectively immutable" comment is rewritten: RIGHT. Step 3.7's paragraph (:3940) now states the stored re-point. The WriteImageCheckSeam doc and the removal test's header are rewritten too.
  7. The update preview's behaviour is stated: RIGHT. It is in the changeset, in the PR body's Acceptance notes, and in the org and oql pins. objectql: the validate preview (import dry run) runs no tenant write wall, so after PR #25048 it reports organization_id as dropped for a walled insert that the write stores, and it answers valid for a value the write refuses #25062 carries it.

6308267963 ③, what half 2 must not regress:

  1. The setter stays on non-empty tenantParts, never on step 3.6's seam or the crossing seam, and the conjunct is replaced deliberately: HELD (:4369).
  2. No spare on an array update: HELD.
  3. security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278, and the payload half's refusal of D: HELD (:4241, :4243; B2-WALL 16/7/0).
  4. One read for the by-id and predicate seams; isSystem and update-mode validate() unchanged; the census row: HELD (:17641; three census rows).
  5. The honoured guard: HELD (untouched).
  6. The comment: HELD.

6309495055 ③, what half 2 must not regress:

  1. The member only on step 3.7's composed by-id or predicate seam; never on step 3.6's update seam, an array payload or isSystem: HELD.
  2. security(plugin-security): an organization-scoped grant row can be re-scoped by a non-system update so that it applies outside its organization — detail held by the filing seat #22278, without F3 carried across: HELD.
  3. F4: HELD.
  4. sys_ objects measured and ruled: HELD. U3 was measured, ruling 1 applies, and the spare's insert-subject condition keeps every sys_ object's strip.
  5. The gates: HELD.
  6. preserveAudit: HELD.
  7. One helper, and the preview: HELD.

U3, the grant tables: this PR changes nothing for them.

  • sys_user_position and sys_user_permission_set declare organization_id as a plain Field.lookup with no readonly. So no static strip ever took a re-point there, before or after.
  • The spare's insert-subject condition is false for every sys_ object, so even a read-only column there would keep its strip.
  • The only change: step 3.7 now sets the member on their walled update seam, and nothing reads it there.
  • A member's re-point still meets the RBAC administration gate (403). An administrator's still meets the wall and the grant's own guards.
  • They are pinned as controls, and the before-run with main's sources passes them.

The dev's flags:

The dev's out-of-scope findings:

  • An array update payload beside where.id fails with a raw SQL error (no such column: 0), before and after, in process, with nothing stored. Not a boundary item. For the seat to weigh: it is an unenveloped driver error with no carrier.
  • sys_ objects keep the silent update strip under ruling 1. Noted.
  • Half 1's N1, the insert-side stale prose, is unchanged: step 3.7's D2 arm ("the column is read-only to a session payload") and the operator arm ("strips the column from a session payload"). For the seat.

Notes. None blocks.

  • N-a: P1 in ①(e), the unlisted in-process undefined reporting change.
  • N-b: P2 in ①(e), event consumers covered only by the general sentence.
  • N-c: settleMasterDetailLanding's declared staticStrip type omits the optional member, so the type does not document that it travels. A future rebuild of the descriptor there would drop the member silently. That is harmless for the FK question it asks.
  • N-d: the system-data app-object spare is probed, not pinned.

Check-runs on 784b1f9f3, read last, at 2026-10-21T09:46Z: 41 runs; 34 success, 7 skipped, 0 failed, 0 pending. The PR is mergeable (clean), and its head has not moved.

  • Success:
    • Test Core 1/6 to 6/6 and the rollup;
    • Build Core;
    • TypeScript Type Check and the four Type Check lanes;
    • Lint & Repo Gates and the eight Lint lanes;
    • Check Changeset;
    • Governed Surface Queue Guard;
    • Dogfood Regression Gate;
    • the four claim and closing-keyword guards;
    • Check Documentation Links, Flag docs affected by code changes, Auto Label, Check PR Size, and filter.
  • Skipped (opt-in or main-only): 18.x upgrade harness, Packed-tarball smoke, Build Docs, Console Pin Gate, the Dogfood Regression Gate matrix, Dogfood Verify CLI, Temporal Conformance.

Implemented-by: claude/issue-24822-update-repoint-honoured
Reviewed-by: session_01UmqzD97e14iws6hmVhgZ5y

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24822 half 2 (UPDATE, the last half) · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-21T09:47Z · head 784b1f9f31.

  • This is the review of record for the claim 6305515833, widened in place for half 2.
  • Contract review: owed on the declaration face (Clause-②: yes). PASS at TIER_DEFAULT on this same head (6312988749, an isolated reviewer, Local-runs: none). It judged every point of half 1's two "must not regress" lists (6308267963 ③, 6309495055 ③) and the seat's seven half-2 rulings as HELD.
  1. Form:
  2. Premise and fix:
  3. Gates:
    • CI on 784b1f9f31, latest run per check name: 41 checks, 34 success, 7 skipped, 0 failed.
    • Every skip is on the roster in scripts/pm/check-expected-skips.mjs: Temporal Conformance, the Dogfood Regression Gate matrix, Console Pin Gate, Dogfood Verify CLI, Build Docs, Packed-tarball smoke and the 18.x upgrade harness.
    • Lint & Repo Gates, TypeScript Type Check, Build Core, all six Test Core shards, Dogfood Regression Gate, Check Changeset and the closing-target guards are success. mergeable_state is clean.
    • Local: 76 derived, 76 run, 0 NOT-MEASURED.
      • objectql 458 files / 8610 passed;
      • plugin-security 205 / 4193;
      • organizations 15 / 233;
      • three typechecks with check:test-typecheck OK, and no debt signature.
  4. Accepted deviations:
    • The stored-view helpers gained an optional descriptor member, which reaches settleMasterDetailLanding unedited. The review judged it right (its N-c is a typing note).
    • A prose-only header edit in tenant-wall-organization-removal.test.ts.
    • origin/main was not merged a third time. The new commits share no file with the 9 paths.
    • The model-free commit trailers, per AGENTS.md.
  5. Out of scope:
  6. Landing: ordinary queue landing. Every check on this head is green or an expected skip, so the seat flips it on this record. It closes group posture: a member's insert whose organization_id names another organization of its membership set is silently stored in the active organization (the engine's read-only strip), where #21666 ruled an explicit value meets the wall and is never rewritten #24822.

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