Skip to content

fix(objectql): settle readonlyWhen locks per cycle, so a cycle no longer over-locks the rest of the update - #19979

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19929-readonlywhen-per-component-fallback
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19929-readonlywhen-per-component-fallback

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19929
Clause-②: no

It removes a drop that published text already rules out: content/docs/data-modeling/fields.mdx says the server "ignores writes to fields whose readonlyWhen predicate is TRUE". The dropped x below has a FALSE predicate on the row the update stores.

What changes

settleReadonlyWhenDrops (packages/objectql/src/validation/rule-validator.ts) searched for ONE drop set across every readonlyWhen key in the update. When a cycle stopped that search from settling, it kept the fixpoint's larger set for the WHOLE update. Now the keys are settled in groups: the strongly connected components of "the predicate of k reads j through record", taken in dependency order.

  • A key in no cycle is judged once, after every key it reads, against their final drops.
  • Keys that read each other in a cycle are settled together, with the existing fixpoint, release rounds and fail-safe fallback. The bound is now the cycle's size, and the fallback covers only that cycle's keys.
  • What a predicate reads comes from the AST of the canonical parse (parseCelToAst). Only a . or .? select on the bare record root counts as reading a named field. Any other use of record (record['b'], 'b' in record, size(record), a macro-bound record), a non-CEL predicate, or one that does not parse counts as reading every judged key. previous and parent are fixed within one strip call, so they add no edge.

engine.ts is unchanged (H4). The master-detail settlement calls the same strips; the new line_beside pins show its FK landing beside a cycle with the same header reads as before (['h_open', 'h_open']).

I chose strongly connected components over the card's connected components on measurement (table below). Connected components still over-lock a key that is in no cycle but is connected to one, for example a cycle that reads x. The per-field sentence the triage asked to make true ("only where locks read each other in a cycle") holds only with the per-cycle grouping.

Measurements

H1 holds on main 2c1011b01b, read from the code: the function's last line returns the fixpoint's set for the whole judged list.

H2 holds on main. The new suite, run before the fix, was 17/17 red. In both card shapes the update stored x: 'old', so all five fields dropped. The pure strip returned { x: 'xv' } for the chain alone and {} beside the cycle. The strict refusal named ['c', 'x', 'y', 'a', 'b'].

H3 was tested with a brute-force probe: a temporary vitest file, not committed, run against the new strips and the BASE strips copied from 2c1011b. It covered 16,500 random lock systems, 12,000 single-row and 4,500 bulk with 2 or 3 matched rows, some with index reads. Every check came back with 0 violations:

  • (S) no kept key is locked on the row the update stores (a key is judged with its own incoming value);
  • (E) every dropped key that is unlocked there is in a cycle, under the reading above;
  • (L) each key's verdict is unchanged when the payload is cut down to the keys that key transitively reads;
  • (D) shuffling field declaration order and payload key order never changes the drops;
  • (C) an acyclic system gives the same answer as base;
  • (R) the update drops nothing exactly when base drops nothing, so strictReadonlyWrites refuses the same writes.
over-locked keys (dropped while unlocked on the stored row) base connected components per cycle (this PR)
total 664 661 575
outside any cycle 90 87 0

Base and this PR agree on 16,405 of the 16,500 systems. They differ in 95:

  • 86 where both fall back and this PR over-locks fewer keys;
  • 5 where both reach an agreeing set, but a different one;
  • 4 where base reached an agreeing set and this PR keeps the cycle's fail-safe drops (see the acceptance notes).

The rule-validator blob the probe measured is 87b0b5a7, unchanged since ebb8a761 through this head.

Ablation of the conservative reader. I committed first, then ran node scripts/ablation-replace.mjs in WRAP mode to change every = true; to every = false; (anchor 1 → 0, blob 87b0b5a7 → 9b744861). Exactly the two index_read pins went red. The ablated update stored w: 'wv' although w's lock (record['a'] == 'old_a') is TRUE on the stored row, so the lock opens. The restore was proven: blob equal to HEAD, and git diff HEAD empty. The suite imports ./validation/rule-validator.js through a relative path, so the subject resolves to src, not dist, and no dist preflight applies. The fixtures declare z/w ahead of the cycle. Groups with no read between them come out in declaration order, so a cycle declared first would have hidden a missed read.

Tests (head 2d75a593)

Gates

I ran node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack from this diff. It derived 63 commands; I ran those plus the 2 families only the dispatch named (check:stack-collection-maps, check:swallow-census-controls). --ran reconciles: 63 derived, 61 run, 2 NOT-MEASURED, 0 UNRUN.

  • NOT MEASURED, PREREQUISITE NOT MET (exit 3): check:dual-build-cjs-loads and check:type-check-debt. Both need every workspace package's dist/, and only the objectql closure is built here. As a narrower check, @objectstack/objectql's own build succeeded (ESM, CJS and DTS), and its CJS entry loads (require exit 0, 176 exports).
  • check:engine-split-ratio --days 90 refused on the shallow clone (exit 2). After git fetch --shallow-since=2026-06-19 origin main it passed (exit 0).
  • Red by design: check-empty-changeset --base origin/main (exit 1), the DELIBERATE CORRECTION class. This PR rewrites the pending .changeset/19911-readonlywhen-interdependent-locks.md, whose sentences this PR made false:
    • its mechanism paragraph said the release rounds stop "after one round more than the caller sent fields" and that the larger set "stands instead" for the whole update;
    • its cycle bullet said a FALSE-lock field is dropped "only where locks read each other in a cycle", which was true for the update and false per field.
      Both now describe per-cycle settlement. The pending .changeset/19927-readonlywhen-exact-drop-set.md carries no such sentence, and every example in it still holds on the new pins, so it is untouched. Neither changeset had been consumed by a release at base 2c1011b. This correction of a pending release note needs a maintainer's confirmation on this PR; the gate stays red until then. I did not add a skip-changeset label.
  • All other commands exit 0.

Serial neighbour

Draft PR #19728 is still open. I fetched its head 3b9c5f2f into refs/issue-19929/pr19728 and ran git merge-tree --write-tree --name-only of this head against it from a throwaway bare clone with no merge driver: exit 0, tree 80a7d59f, no conflicted paths (merge base 6eaa0f4a). That PR rewrites the @objectstack/formula import on line 195, so this PR imports parseCelToAst in a separate statement further down the import block.

Acceptance notes

  • A delta inside cycles with two or more agreeing sets. In 4 of the 16,500 probe systems, base reached one of a cycle's agreeing sets through the whole-update trajectory, and this PR keeps that cycle's fail-safe drops instead. Example: k1: previous.k1 == 'q', k2: record.k1 == 'q', and the cycle k0: record.k3 == 'p' && record.k2 == 'q' / k3: record.k0 == 'q', on the row {k0: p, k1: q, k2: q, k3: q} with {k0: q, k1: p, k2: p, k3: p}. With k2 settled first the cycle agrees with both {k0} and {k3}, and the release rounds alternate between {} and {k0, k3}. Base's first pass judged k0 against a k2 it had not dropped yet and landed on {k3}. Such a cycle's pick was always an artifact of the iteration (the documented rule is "the one ② reaches, or ①'s when it reaches none"). It now depends only on the locks the cycle reads. The 19929 changeset states this. No pin flipped.
  • A predicate that reads record other than as a field select counts as reading every judged key. In the probe that created a cycle that was not really there 9 times, each over-locking in the fail-safe direction. This is the conservative direction the ablation above proves is load-bearing.
  • fields.mdx never mentions the residual over-lock inside a cycle. The docs are incomplete there, not wrong, so I noted it and filed nothing. Carrier: none.
  • The onFieldsDropped event lists fields in the payload's key order (reportDroppedFields walks the payload). That is existing behaviour, and the order pin in the new suite states it.

Generated by Claude Code

A readonlyWhen lock is now judged after the locks whose fields it reads,
and the locks that read each other in a cycle are settled together, so a
cycle that does not settle falls back to its fail-safe drops for its own
fields only. A chain of locks in the same update is settled as it is alone.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
… per update

Adds the changeset for the per-cycle settlement and corrects the pending
readonlyWhen changeset's mechanism and cycle sentences, which described one
search and one fallback over the whole update. Pins the isSystem caller.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
Groups with no read between them come out in declaration order, so a lock
declared after the cycle is judged after it whatever its predicate reads.
Declared first, only the read analysis can order it after the cycle.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 anchor(s) derived from 1 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • 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 — 17 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 2c1011b01bc071c545f72f2761647b8d9ab56375 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2c1011b01bc071c545f72f2761647b8d9ab56375

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

@objectstack-fleet

objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2d75a59332ddea43798e08ebe82016359c972f52

① Derived judgments

  1. settleReadonlyWhenDrops now settles the judged keys in strongly connected components (Tarjan) of "k's predicate reads j through record", in dependency order. settleLockGroup judges a one-key group once, against the final drops of the groups before it, and runs the fixpoint, release and fallback for a cycle only. RIGHT.
  2. Single-row updates: a caller field whose lock is in no cycle is stored exactly when its lock is FALSE on the row the update stores, and ignored exactly when TRUE. The card's x now lands. Measured: the real engine at head stores x: xv where base stored x: old. The reviewer's own probe found 0 kept keys locked on the stored row and 0 dropped-while-unlocked keys outside a cycle, across 11,885 random lock systems. RIGHT.
  3. Bulk updates: the same holds with "locked in at least one matched row", across 2,963 bulk systems and six bulk pins. RIGHT.
  4. Master-detail: engine.ts is unchanged; settleMasterDetailLanding still judges the FK, now through the grouped settlement. The invoice example moves the line to h_open, stores amt, and ignores c, y, a, b. RIGHT.
  5. onFieldsDropped / strictReadonlyWrites now name only the fields actually ignored. Whether a write is refused is unchanged: 0 of 11,885 differ. RIGHT.
  6. No lock opens: 0 of 11,885, and no spelling in a 20-spelling battery let a locked field land. RIGHT.
  7. The read-set extraction is conservative. A field is named only for a . / .? select on the bare record root; any other use of record, a non-CEL dialect or a parse failure counts as reading every field. Verified against the cel-js 8.0.0 AST for 37 sources, including record['b'], size(record), macros, dyn(record).a and 'a' in record. RIGHT.
  8. Deterministic, and independent of declaration and payload order: 0 of 11,885 differ with both orders shuffled. RIGHT.
  9. The per-cycle fallback is bounded; the loop bounds match the docblock. RIGHT.
  10. Acyclic parity with base: 0 differences across 8,754 acyclic systems. All 13 moved answers are inside cycles, in the fail-safe direction. The dev's acceptance-note example reproduces. RIGHT.

② Semver level

@objectstack/objectql: patch with Clause-②: no is consistent. No authorable key, export or error code moves: the new functions are module-private, and engine.ts and packages/spec are untouched. Every movement goes toward the cited fields.mdx text. Check-runs at head: 34 runs, 30 success, 3 skipped, 1 failure. The failure is Check Changeset, the DELIBERATE CORRECTION class of check-empty-changeset.mjs, red by design pending written confirmation. All seven required contexts are green.

③ Boundary flags

  1. .changeset/19929-readonlywhen-cycle-isolation.md: every factual sentence and worked example holds on measurement (c/x/y beside a/b; the two-agreeing-sets case; the invoice case; the z / w case; the record['b'] / size(record) sentence; the onFieldsDropped sentences). No false or unmeasured sentence. One imprecision, not a FAIL: "counts as reading every field the update writes" is implemented as an edge to every other judged key, which has the same consumer-visible effect.
  2. The corrected note, .changeset/19911-readonlywhen-interdependent-locks.md (pending; a DELIBERATE CORRECTION under check-empty-changeset.mjs). Each rewritten sentence is judged against the code at this head:
    • "A lock is judged after the locks whose fields it reads, with each value they drop put back to the row's stored one." TRUE: dependency order of the SCC groups, withoutKeys(stored, dropped).
    • "Locks that read each other in a cycle are judged together, again with each dropped value put back, until no further field locks." TRUE: the cycle fixpoint in settleLockGroup.
    • "Then the fields held only by a value that was later put back are released, and every lock in the cycle is judged again against what that release stores, round after round, until the dropped fields are exactly the ones locked on the row the update stores." TRUE: the release rounds, scoped to the cycle.
    • "after one round more than the cycle has fields, the first, larger set of drops stands for the cycle's fields instead." TRUE: the first release set plus m sets, then the fixpoint set for that group's keys only.
    • "only when its lock is in a cycle of locks that read each other's fields (a parent-scoped lock counts as reading the master-detail field …, and a lock that reads record other than as record.FIELD counts as reading every field)." TRUE: recordFieldsRead; both conventions measured.
    • "A field whose lock is in no such cycle is dropped exactly when its lock is TRUE on the row the update stores (on a bulk update, on at least one matched row)." TRUE: 0 of 11,885 violations, single-row and bulk.
    • "Where a cycle's drops do not settle, the first, larger set stands for that cycle's fields: a lock the update cannot settle is not waived." TRUE.
    • The untouched paragraphs, including the a/b no-set and two-set examples: head drops both in each, and they stay pinned by the A record-scoped readonlyWhen reading a field that another readonlyWhen drops in the same pass is judged against the dropped value, so a closed row locked amount is rewritten #19911 suite.
  3. Dev open question (the 19911 correction): option A is right. Restoring the base text would republish a whole-update bound and fallback the shipped code no longer has.
  4. Dev deviations are all real and sound:
    • SCC is better than connected components (the out_of_cycle pin shows the difference).
    • The H3 wording flag is honest; the invariant actually measured is the contract's.
    • The 4-in-16,500 delta is real, cycle keys only and fail-safe, and is stated in both changesets.
  5. Dev out-of-scope observations: both are real and neither is a filing class. The fields.mdx silence on cycle over-locking is a docs gap. The payload-order onFieldsDropped listing is pre-existing and pinned.
  6. The tests pin the fix and can fail: 18 of 18 red against the merge-base validator. The every = true to every = false ablation reddens exactly the two index-read pins.

Implemented-by: claude/issue-19929-readonlywhen-per-component-fallback
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

Isolated reviewer: a separate contract-review-tier subagent, fed the card, the parent card, the PR, fields.mdx and AGENTS.md only; adopted by the seat.


Generated by Claude Code

@objectstack-fleet

objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Pending release-note correction on this PR: Check Changeset is red by design, awaiting written confirmation

domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-24T14:31Z.

Per check-empty-changeset.mjs (the foreign changeset rule, DELIBERATE CORRECTION class) and pr-automation.yml route 0, this PR names the note it corrects and what changed under it, here, and leaves Check Changeset red until that is confirmed in writing.

Confirmation, per ruling 1A (#19940, record 5814546887, batch #220 item 3, maintainer 2026-09-24T12:56Z): 「an at-tier contract-review PASS record on the same head that names the corrected note and judges each rewritten sentence is the confirmation. The seat enqueues under the three-condition red-by-design path.」 That record is 5816146511 at 2d75a593. The three conditions: ① the gate states it stays red by design (check-empty-changeset.mjs, the DELIBERATE CORRECTION class); ② Check Changeset runs in pr-automation.yml, which triggers on pull_request only, never merge_group; ③ this comment records the gate and the reason. (Superseded wording: an earlier revision of this comment routed the confirmation to the decision box. It was written before the seat read ruling 1A.)


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 14:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit c839986 Sep 24, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19929-readonlywhen-per-component-fallback branch September 24, 2026 15:07
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… of an array insert and a predicate update (objectstack-ai#19988)

Fixes objectstack-ai#19950
Fixes objectstack-ai#19964
Clause-②: no (narrowing)

## What this fixes

A row-level security `check` (declared on the policy, or defaulted from
its `using`) is the write-side half of the policy: a row the check
refuses is never stored (ADR-0058 D4: "on the write pre-image path that
already exists for by-id writes … and on the AST-injected bulk path").
The write gate enforced it for a single-row insert and a by-id update,
and not for the two multi-row write shapes:

- **objectstack-ai#19964, array insert.** Step 3.6 excluded an array payload, so no
judgement was installed and every row was stored unjudged, including
under configurations that refuse every single-row insert.
- **objectstack-ai#19950, predicate update** (`multi: true`, no row address). Step 3.6
skipped the new-row check and logged "governed by the using-scoped
where". A policy that declares only `check` scopes nothing, and a scoped
`where` says nothing about the new row in any case. The skip was
unconditional, so it also covered a declared `check` that differs from
`using` and a `check` defaulted from `using`.

Both shapes are now judged row by row with the existing refusal
(`PERMISSION_DENIED` / 403, nothing stored). One failing row refuses the
whole write. The judgement is the existing `satisfiesCheck` over
`matchesFilterCondition`: no predicate is compiled differently, and
nothing is lowered into the `where`, so no compile surface moves.

## Landing: two packages, and why the engine is one of them

- `packages/plugins/plugin-security/src/security-plugin.ts`, step 3.6
plus the seam declaration and the post-`next()` fail-closed guard
(generalised from "the insert" to the operation). The by-id update
branch is unchanged. The `explainAccessForCaller` wiring is not touched.
- `packages/objectql/src/engine.ts`, the predicate-update branch of
`update()` (`domain:engine`), plus the seam's doc comments. **Why the
producer is the engine (measured, not assumed):** the rows a predicate
update changes are the rows the middleware-COMPOSED AST selects. That
AST is complete only after every middleware has run: step 3 of this
middleware composes its own scope after step 3.6, and plugin-sharing
composes its editable-rows filter onto the same AST
(`sharing-plugin.ts:1405`), in plugin order. The suggested route,
reading "under the caller's context" in the middleware, was measured two
ways and does not hold:
- A caller-context read applies READ scope and field masking. Where the
read scope is narrower than the write scope, written rows go unjudged
(fail-open). Where it is wider, rows that will not be written get judged
(false refusals). Ablation 3 below shows the second: a judgement that is
not over the actual matched rows falsely refuses the USING-only in-scope
control.
- The engine already holds the exact set: the D7 matched-row read
(`readPriorRows`, bound to the composed AST), which the ruling says is
read once and reused, and which already serves validation, the
`readonlyWhen` strip and both per-row hook phases.
  
So the security layer installs its judgement on the existing
`OperationContext.postHookWriteImageCheck` seam (the one objectstack-ai#19952 built
for inserts), and the engine calls it on the predicate branch. It hands
over every matched row merged with the payload (the same shape as the
per-row `afterUpdate` `result`), placed after `assertNoStrictDrops()`,
where the payload is final. That placement follows the insert seam's
contract review: "the row the seam judges must be the row that is
stored". The readonly strips run earlier on this branch, so a pre-strip
placement would judge values that never land.

## Mechanism hypotheses (dispatch Section 2), as measured on
`2c1011b01b`

1. **Held.** Line 3000 carried `!Array.isArray(opCtx.data)`. Lines
3078-3083 set `postImage = null` for `extractSingleId(opCtx) == null`
and logged "governed by the using-scoped where".
2. **Held.** `engine.ts` (the `postHookWriteImageCheck` call in
`insert()`) hands `evaluate` every live row of an array insert. The
array fix is plugin-side only.
3. **Refined.** The memoized `getCallerPreImage` is by-id and
caller-context, so it is not reusable per row for the reasons above. The
engine's memo serves instead, at no extra read wherever per-row hooks
already read it.
4. **Held.** The skip was unconditional. A cell pins a `using` plus a
differing `check`.

One further hole, found and closed: the middleware treats a falsy scalar
id (`''`, `0`) as a row address, and the engine does not
(`resolveEngineUpdateDispatch`). A falsy payload id therefore carried a
bulk update past the per-row judgement, admitted on a change-set-only
image. The seam is now installed whenever the engine will not treat the
write as addressing one row. The falsy case keeps its by-id judgement
too, so it only refuses more.

## Surface beyond the claim, with reasons

- `packages/objectql/src/engine.ts`: see above (cross-lane,
`domain:engine`).
- Existing plugin-security tests: `check-only-write-scope.test.ts` and
`security-plugin.test.ts` carry engine doubles that must now honour the
seam on a predicate update, the way the real engine does. Otherwise the
fail-closed guard refuses them, which is the intended behaviour. One
test title and comment said step 3.6 "declines to check" the bulk path;
it now says 3.6 can refuse a bulk write but never scope one. That pin
still discriminates a site-1 revert, now by refusal. Two comment-only
edits (`rls-check-defaults-to-using.test.ts`,
`rls-phantom-column-negation.test.ts`) stated the array exclusion as a
fact.
- **A pending release note, corrected in place:
`.changeset/rls-check-defaults-to-using.md`** (from objectstack-ai#19952, not yet
released). Its "What does not change" list said bulk updates "are not
checked row by row", which this PR makes false. The bullet now says
their new rows are checked row by row too, by this PR's entry.
`check-empty-changeset` is RED on this by design: it is the DELIBERATE
CORRECTION class (ruling D on objectstack-ai#17712). Its prescribed remedy is to keep
the correction and get it confirmed on the PR; restoring the file would
publish a false sentence. Under the landing rule objectstack-ai#19970 set, 「DELIBERATE
CORRECTION 红:同 head 达档复核 PASS 记录即确认,⛔ 不等维护者」, the confirmation is a
same-head at-tier review record with a PASS verdict, not the maintainer.
The red is expected until that record is on the PR for the landing head.

## Behaviour that changes (all in the refusing direction)

- A predicate update under a check-only policy is refused when any
matched row's new image fails the check.
- A predicate update that moves a matched row out of a policy's `using`,
when no applicable policy declares `check`, is refused: the defaulted
check, which is the answer the by-id update has given since objectstack-ai#19952.
Triage's "已声明 `using` 的策略,行为保持不变" is held as: in-scope bulk updates
under a `using` policy are admitted and scoped exactly as before
(pinned). Only a bulk write that moves rows out of the `using` is newly
refused, on the same terms as by-id. The seat confirmed this reading:
by-id and bulk are two implementations of one operation and must not
disagree (`RowLevelSecurityPolicySchema.check` 「defaults to USING clause
if not specified」; ADR-0058 D4).
- An array insert is refused when any row fails, including every
configuration that already refused each single insert.
- A host that installs the judgement on a predicate update and never
runs it is refused (403, `error` log), as an insert already is.

## Tests

**Patch round 2 (head `3247efeecd`, after merging `origin/main`
`9bfbacbf8b` as merge commit `938b2acfde`):**
`rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security
suite 123 files, 2348 tests passed; objectql re-run because objectstack-ai#19979
touched that package: local project 155 files / 2434 tests + 154 files /
2758 tests, repo project 1 file / 5 tests; `typecheck` green for
objectql and plugin-security (VERDICT command-exit 0 each).

**Patch round 1 (head `9fff66cfdc`, after merging `origin/main`
`3fd3a4f91b` as merge commit `a5ca1db166`):**
`rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security
suite 123 files, 2348 tests passed (VERDICT command-exit 0 each). The
merge brought no change under `packages/objectql` or
`packages/plugins/plugin-security`, so the objectql suite was not
re-run; its last run is the one below, on a byte-identical `engine.ts`.

**Round 1 (head `6d28dfe98d`):**


New:
`packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts`,
32 cells on driver-sql (better-sqlite3) and driver-sqlite-wasm, real
`SecurityPlugin` + `ObjectQL`.

- Failing first, on the unmodified tree: 18 red and 12 green (the
controls). Every negative cell failed as "admitted" (`expected true to
be false`). In the unresolvable-policy cells the single-insert leg was
refused and only the array leg was admitted.
- objectstack-ai#19950 cells: the check-only repro; per row not per change set (a
matched row failing on an unchanged field refuses the whole write);
`using` plus a differing `check`; fail-closed (an inner middleware
strips the seam, and the write is refused with "the update on
'qa_ticket' was executed without the row-level CHECK being evaluated");
falsy payload id. Controls: over-fix admit, USING-only in-scope admit
and scoped, `using`+`check` in-scope admit, by-id unchanged, and
USING-only move-out refused on both bulk and by-id.
- objectstack-ai#19964 cells: the `[admitted, refused]` repro; over-fix admit; single
insert unchanged; three refuse-every-insert configurations (an
unresolvable sole `using` on `insert` and on `all`, an unresolvable
declared `check`).
- Every refusal asserts `code` `PERMISSION_DENIED`, `status` 403 and the
developer half naming the gate and verb, then reads the stored rows back
under a system context.

Suites: plugin-security 123 files, 2348 tests pass. objectql 309 files,
5182 tests pass (local project in two halves, plus the repo project).
`typecheck` is green for both packages (plugin-security test-layer debt:
0 files, 0 errors).

Ablations: each committed first, mutated through
`scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed),
restored with blob equal to HEAD and an empty `git diff HEAD`.
Resolution path: the plugin is imported relatively, and
`@objectstack/objectql` is aliased to `src/index.ts` in this package's
`vitest.config.ts`, so no `dist/` sits between the mutation and the
test.

| # | mutation | result |
|---|---|---|
| 1 | restore the non-array guard for inserts | 8 red: the 4
array-insert negative cells x 2 drivers |
| 2 | never install the seam on a predicate update | 12 red: the 6 bulk
negative cells x 2 |
| 3 | engine judges the payload alone, not the matched rows | 6 red: the
per-row cell, the falsy-id cell, and the USING-only in-scope control
(falsely refused) |
| 4 | install only when the id is null (falsy counts as by-id) | 2 red:
the falsy-id cell, admitted |
| 5 | engine never calls the seam | 16 red: refusals now carry the
not-evaluated message, controls refused |
| 6 | disable the post-`next()` fail-closed guard | 2 red: the
fail-closed cell, admitted |

## Gates

**Patch round 2 (head `3247efeecd`):** the four comment lines this
change rewrote now cite the surviving record, commit `a016f08b8a` (the
insert-side check), instead of a card that answers 404, and say in words
that the original card no longer resolves. `GITHUB_TOKEN="$GH_TOKEN"
node scripts/check-issue-citations.mjs` probes the board and exits 0:
"every citation this change adds resolves (or is a declared cross-repo
reference)", with 9 judged, 9 resolving and 0 unresolved added.
`dispatch-gates --commands` derived the same 68 families from the same 9
paths against merge base `9bfbacbf8`. All 68 were run; `--ran` answers
"68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0.
`check-empty-changeset` exits 1 on
`.changeset/rls-check-defaults-to-using.md` only.

**Patch round 1 (head `9fff66cfdc`):** `dispatch-gates --commands`
derived the same 68 families from the same 9 paths, now against merge
base `3fd3a4f91`. All 68 were run; `--ran` answers "68 derived, 68 run,
0 NOT-MEASURED, 0 UNRUN". 67 exit 0. `check-empty-changeset` exits 1 on
`.changeset/rls-check-defaults-to-using.md` only (the deliberate
correction under "Surface beyond the claim"). The changeset gates the
seat named: `check-adr-0087-registration --base origin/main` exits 0 ("1
declared-breaking changeset(s), each carrying an ADR-0087 disposition"),
`check-changeset-no-major --base origin/main` exits 0, and `pnpm
check:changeset-gate-self-tests` exits 0.

**Round 1 (head `6d28dfe98d`):**


- `node scripts/pm/dispatch-gates.mjs --commands` derived 68 families
from the 9 changed paths. All 68 were run with exit codes recorded.
`--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN".
- 67 exit 0. One exits 1: `check-empty-changeset`, the deliberate
correction above.
- Three first answered `PREREQUISITE NOT MET` (exit 3):
`check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`.
They pass after the workspace closure build. `check-engine-split-ratio`
passes after deepening the shallow clone to its window.
- Lint, as a proven narrowing: `eslint --no-inline-config --format json`
over the 7 changed `.ts` files reports 7 files linted, 0 errors and 0
warnings. All 7 fall in the config's `**/*.{ts,…}` block. The config
never enables type-aware linting (`eslint.config.mjs:326-328`), so this
diff cannot move a verdict on an untouched file. The full `pnpm lint` is
CI's.

## Acceptance notes

- **Refusal cost on the predicate path.** The judgement runs where the
payload is final, after the per-row `beforeUpdate` hooks and after the
credential channel (`encryptSecretFields`), which runs above the strips
on this branch. A refused bulk update whose payload carries a `secret`
field has therefore already minted its `sys_secret` row. A validation
refusal two lines below pays the same cost today. Moving the credential
channel below the strips is a separate engine change. A `check` naming a
secret field judges the stored reference.
- **Image timing differs between the two update paths.** A predicate
update is judged on the post-hook image; a by-id update is still judged
in the middleware on the pre-hook change set merged with the
caller-visible pre-image. Where a `beforeUpdate` hook rewrites a checked
field, the bulk path is the stricter of the two. The by-id path is
untouched here.
- **Read cost.** On an object with no per-row hooks, a checked predicate
update now reads its matched rows. The read is unbounded by the per-row
hook ceiling, which applies only when hooks dispatch. On a kernel with
the usual global hooks the read already happens and is shared.
- **Partial-row array insert** (`__partialRowErrors`): a failing row
refuses the whole call rather than being reported per row.
- **Version skew.** A plugin-security built from this change, run over
an engine without the predicate-path call, refuses checked bulk updates
(fail-closed). Both packages carry the changeset.
- `.changeset/19950-rls-check-multi-row-writes.md` is declared a
narrowing, per the seat's ruling and following objectstack-ai#19952: `minor` for
`@objectstack/plugin-security` and `@objectstack/objectql`, a `!`
headline, `Clause-②: no (narrowing)`, an ADR-0087 `not-required
(no-migration-prescription)` disposition, and a **BREAKING** paragraph
listing the newly refused writes and the remedy (declare `check` on the
policy, or fix the data).
- `origin/main` was merged twice with merge commits, both clean with no
regeneration owed: at `3fd3a4f91b` (`a5ca1db166`) and at `9bfbacbf8b`
(`938b2acfde`). PR objectstack-ai#19984 (objectstack-ai#19963, the explain wiring in the same file)
had not landed by the second merge.
- Patch round 2: citation fix only (four comment lines in
`security-plugin.ts`); no behaviour change.

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ough the allow-listed ccr pair; a landing denial stops and surfaces (objectstack-ai#19997)

Fixes objectstack-ai#19990
Clause-②: no

Rule text only, in three `pm-dispatch` references. This PR adds no allow
row, no tool and no gate. `.claude/settings.json`, `scripts/pm/**`,
`SKILL.md` and `AGENTS.md` are untouched. Line counts are unchanged (183
/ 101 / 37), and every edited line is at or under 120 bytes.

The maintainer's words, in the `domain:engine#1` seat's session, quoted
on the card verbatim and in order:

> 「你的pr为什么没有挂在当前session上」
> 「写一个 skills 卡片,更新技能」
> 「包括你刚才为什么不能merge,我当前session设置的是auto」

The same words reached the `domain:skills` seat directly (claim comment
5817962037): 「你的pr应该挂在当前 session上,对应的卡片优先派发」.

## What changed

| file · line (after) | bytes | rule |
|---|---|---|
| `execution-duties.md` :149 (new) | 118 | Case 1. When a report names a
PR, a session seat subscribes it at once (`subscribe_pr_activity`) and
lists it on the seat post. Reason, stated once: a PR the relay opens is
never attached to the session automatically. |
| `execution-duties.md` :147 | 87 → 116 | The collection line now covers
both modes itself ("(两种模式)", "评论与返回消息皆无"), replacing the deleted
report-channel line (see *Line budget*). |
| `landing-operations.md` :49 | 82 → 117 | (b). The landing executes the
verdict of record (ACCEPT, or the contract-review PASS). It is not a
self-approval. |
| `landing-operations.md` :51 | 65 → 115 | (a). Ready and auto-merge go
only through the two ccr commands that `settings.json` allow-lists
(`rest-channel.md` :51 / :55). |
| `landing-operations.md` :54 (new) | 120 | (c). A classifier denial
during landing means: stop, report to the maintainer, and record the
command and the denial reason on the card. ⛔ Never respell the command
or switch to the relay to get around it. |
| `landing-operations.md` :77 | 106 + 89 → 111 | Case 1, landing side.
Every PR in a session seat's window must be subscribed; subscribe any
that is missing. The optional 「关键 PR」 wording is gone. The old :77 "not
before the report" clause is folded in as 「⛔ 不早于报告」. Routine seats keep
polling. |
| `reading-discipline.md` :23 | 82 → 120 | (d). A timer text carries no
verdict or landing write verb. |

Wording choices that differ from the dispatch text:
- **`判决`, not `裁决`, at :49.** In this corpus `裁决` is a maintainer
ruling, and `判决` is the review verdict (`execution-duties.md` :180–:183,
「判决 ACCEPT / REWORK / ESCALATE」).
- **`判决与落地类写动词`, not only `落地类写动词`, at :23.** The timer that was denied
`[Self-Approval]` told the seat to post the ACCEPT as well as run the
two landing ops.

## Why no new allow row is owed: case 2 (a)

The allow-listed landing route already exists. The skill already names
it, and this PR only makes §B's landing step name it too.

- `.claude/settings.json` :61–:66 allow-lists `curl -sS -X POST
…/pulls/*/ccr/ready_for_review` and `curl -sS -X PUT
…/pulls/*/ccr/auto_merge` for all three repos. The hotcrm pair was added
on 2026-09-24 by `e6a5ecb9`. That commit also deliberately gave
`fleet-write/dispatch.mjs` no row. `git grep -n 'with-fleet'
.claude/settings.json` gives 0 hits; the control `git grep -n
'label-write' .claude/settings.json` gives 2.
- `SKILL.md` :201 says 「ready/draft 走 ccr 路」, and `platform-readings.md`
:48 says 「undraft 单通道:席位凭据走 `POST .../pulls/{n}/ccr/ready_for_review`」.
- Measured on the timeline (`GET /issues/N/timeline`,
2026-09-24T16:3xZ):
- PRs objectstack-ai#19873, objectstack-ai#19895, objectstack-ai#19902, objectstack-ai#19941, objectstack-ai#19948, objectstack-ai#19956, objectstack-ai#19970 and objectstack-ai#19993
were landed by the `domain:skills` seat under auto mode. Each has
`ready_for_review` and `added_to_merge_queue` with actor `os-zhuang`
(the ccr route, which writes as the seat's linked user).
- PRs objectstack-ai#19971, objectstack-ai#19972 and objectstack-ai#19979 have the same two events with actor
`objectstack-fleet[bot]` (the relay route).
- Both routes work. The ccr pair is the one with an allow row. The relay
route has none, so under auto mode the classifier judges it call by
call.

## Where the standing authorization is recorded: case 2 (b)

It is already recorded in the tree, so this PR adds only the one clause
at :49:
- `AGENTS.md` Prime Directive objectstack-ai#14: Tier S lands "by the owning seat on a
contract-tier review of record".
- `AGENTS.md` Multi-agent discipline §7 and Post-Task Checklist step 2:
arm auto-merge on a PR that is green and accepted.
- `landing-operations.md` :59 (Tier S).

Whether that is enough for a seat landing a PR written by its own
`mode:subagent` dev is put to the maintainer below. This PR does not
rule on it.

## Line budget: what left, and where each fact still lives

All three files stand at headroom 0. Each new line is paid for by
deleting content, not by re-wrapping or raising a ceiling.
- **`execution-duties.md` old :147 deleted.** It read 「报告通道统一:GitHub
是两种模式共用的真相源;dev 终报先落 issue 评论、再作返回消息。」
- The dev-side ordering lives in `.claude/agents/os-dev.md` :17–:18
(「报告交付两次,GitHub 优先:同一段 JSON 先作 issue 评论 … 再作为终报消息」).
- "GitHub is authoritative in both modes" lives in `os-dev.md` :324
(「两种派发模式(`mode:subagent` 与 `mode:cloud`)下 GitHub 都是报告的权威源」). It also
stays on the collection line as 「(两种模式)」.
- **`landing-operations.md` old :77, second clause, deleted.** It read
「订阅是感知补充,⛔ 不替代 flip 定点」. The fact lives on:
  - :50: the flip timer is set at ACCEPT.
  - :52: 「CI success webhook 不可靠:⛔ 不坐等」.
- `platform-readings.md` :40: 「订阅来的 `check_suite.completed` 是唤醒不是放行读数」.
  - Its first clause is kept on :77 as 「⛔ 不早于报告」.
- **`landing-operations.md` old :76 rewritten in place.** It dates from
`42af12fe7` (the 2026-08-07 ruling on subscribing *key* PRs). The newer
maintainer words quoted above replace its optional scope.

## Measured risk that stays open

- An allow row does not stop a denial based on content.
`mcp__Claude_Code_Remote__send_later` is allow-listed (`settings.json`
:23, present since before 2026-09-20), yet the engine seat's timer was
denied `[Self-Approval]`. `platform-readings.md` :435 records another
content-based `[Self-Approval]` denial.
- Two explanations are possible: that session did not load this settings
file, or the classifier judges content over an allow row. Which one
holds was not measured. The eight ccr landings are the positive reading.
The new :54 line covers the negative case.
- **Write identity, a tension this PR did not create.** The ccr pair
writes as the seat's linked user, `os-zhuang`, which is in
`GOVERNED_APPROVERS` (`scripts/pm/check-governed-queue-guard.mjs` :576).
Three texts point the other way:
- `AGENTS.md`: "Every GitHub write leaves through `scripts/pm/`, as
`objectstack-fleet[bot]` … ⛔ Never a bare `curl` … write".
  - `SKILL.md` :92: 「批准账号永不跑席位或作其关联用户」.
  - `SKILL.md` :94: 「写侧恒为 `objectstack-fleet[bot]`」.

`SKILL.md` :201 already routes ready/draft through ccr, so this tension
predates this PR. The new :51 states the same route more plainly. The
choice is the maintainer's; see the question below.

## Verification

At `04357257d`, every command from `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` was run, with the exit
code captured before any pipe. All exited 0: 17 derived commands, plus
`pnpm check:pm-governed-prose`, `node
scripts/check-skills-token-ratchet.mjs` and `pnpm
check:pm-settings-deny-roster`. The reconciliation `dispatch-gates
--ran` reports: "17 derived famil(ies) accounted for — 17 run, 0
NOT-MEASURED (a DERIVED zero …)".

Verdict lines:
- `check:pm-skill-ratchet`: `execution-duties.md is 183 lines (ceiling
183; headroom 0)` · `landing-operations.md is 101 lines (ceiling 101;
headroom 0)` · `reading-discipline.md is 37 lines (ceiling 37; headroom
0)`.
- `check:pm-skill-id-lint`: `34 file(s) clean`.
- `check:skill-frame-sync`: `the one declared copy of the decision frame
is internally coherent`.
- `check:nul-bytes`: `OK … no raw ASCII control bytes`.
- `check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET,
unbuilt `@objectstack/formula` / `@objectstack/lint`). It exited 0 after
`turbo run build` for those two packages under the verify lock. That
first run measured nothing; it was not a failure.

No build, test, reverse check or ablation applies to this change: it is
rule text only, with no code path.

## Acceptance notes

- `dispatch-runbook.md` :128 has cloud cards subscribe as soon as the
draft PR exists. The folded 「⛔ 不早于报告」 agrees with it only because a
cloud dev reports at draft-PR time (runbook :135). No change is made.
- `origin/main` was not merged before opening. It moved by one commit (a
`docs(qa)` change touching none of these files), and the queue rebuilds
on current `main`.

## 维护者速读(草稿)

**改了什么**
- dev 报告里点名了 PR,席位当场订阅这个 PR 的动态,并记进座位贴。规则里写明原因:经中继开出的 PR
永远不会自动挂到会话上。落地说明里原来"给关键 PR 挂订阅"是可选说法,现在改成"落地窗口里每个 PR 都要挂上,缺了就补"。
- 落地(转 ready、挂 auto-merge)只走 `.claude/settings.json` 已放行的两条 ccr
命令。席位落地是在执行已记录的复核判决,不是自己批准自己。
- 落地过程中如果被权限分类器拒绝:停手,报给您,在卡上记下命令和拒绝原因;不换写法,也不改走中继绕过去。
- 定时提醒的文本里不再写"判决/落地"这类写操作,只写"到时重读什么、满足什么条件"。

**为什么改**
- 您问了两个问题。
- PR 为什么没挂在当前 session 上:中继开的 PR 不会自动挂上,而技能里只有可选的"关键 PR 挂订阅"。
- auto 模式下为什么不能 merge:engine 席被拒了两次,一次是一条回读命令,一次是一条写着"发 ACCEPT
并落地"的定时器。它随后改走中继落地,而中继没有放行规则。放行的路本来就有:本席今天在 auto 模式下用它落地了 8 个 PR,一次都没被拒。

**风险与代价(含回滚)**
- 只改三个技能参考文件的规则文本。行数不变,每行不超过 120 字节。回滚就是 revert 本 PR。
- ccr 两条命令记在 os-zhuang 名下。而 AGENTS.md 写的是"所有 GitHub 写都经 scripts/pm,以
objectstack-fleet[bot] 身份,永不裸 curl 写",SKILL.md
也写"批准账号永不作席位的关联用户"。这个矛盾早就存在(SKILL.md 本来就写"ready/draft 走 ccr 路"),本 PR
没有新造,只是把它写得更明确。
- 放行规则不保证分类器一定放行。`send_later` 在放行清单里,engine
席那条定时器还是按内容被拒了。所以新加了"被拒就停手上报"这一条。

**席位意见**

**你要做的**
- 回一句话,确认下面两件事,或者指出要改哪一件:
- ① AGENTS.md 第 14 条(Tier S 由所属席位在达档复核记录在案后落地)和 Multi-agent discipline 第
7 条(PR 全绿且已验收就挂 auto-merge),就是席位落地自己子代理所写 PR 的常设授权,不用另外记。
- ② 用 ccr 两条命令落地,算 AGENTS.md「写只经 scripts/pm」这条规则的例外。是把这个例外写进
AGENTS.md,还是改走中继并加一条新的放行规则,都由您决定。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_

Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A readonlyWhen cycle anywhere in an update over-locks an unrelated acyclic chain in the same update: the fail-safe fallback is global, not per component

1 participant