Skip to content

fix(objectql): an update-mode validate() preview judges the stored row merged with the patch (#22445) - #22471

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-22445-update-preview-stored-row
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-22445-update-preview-stored-row

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22445
Clause-②: yes

Patch round 1 — contract review 6080151175, finding ①-8

The defect the review found. plugin-security's read mask (field-masker.ts maskRecord) deletes a hidden field, but a field under a partial-masking rule keeps its key and gets a masked value. The round-0 column filter kept every key the read door returned (hasOwnProperty). So for a partially masked column the preview judged the raw stored value, which gave any caller who can read the row a predicate over a value they only see masked.

The mechanism now (engine.ts, storedRows block). A stored column is kept only where the read door served this caller that very value. That is decided by servedAsStored(served, stored), a module-private structural comparison:

  • Object.is decides primitives.
  • Date compares by time; arrays and plain objects compare recursively.
  • Any other object kind, and nesting past 16 levels, answers "not the same" (fail closed).

Every other column is judged as empty. That covers a hidden column (key deleted), a partially masked column (key kept, value replaced), a masked secret, an internal column, and a read hook's rewrite. So the verdict never depends on a value the caller could not have read in full.

  • File fields. The visibility read passes RAW_FILE_VALUES_CONTEXT_KEY, the spec-declared opt-out for a caller whose subject is the stored form. A readable file reference therefore compares as the id it stores, not as the read door's expanded object.
  • Why value equality and not a declared signal. The engine can see a field's maskingRule but not whether this caller holds the unmask permission; plugin-security decides that. A declared-signal rule would judge every masking-rule field empty for every caller, unmasked ones included, and would miss any transformer that is not a maskingRule (a masked secret, a read hook). The comparison asks the read door's own answer for this caller.
  • Representation, measured on head 1f4ca1504b. A throwaway probe (not committed) wrote one row of every common type through a real engine, then compared the write's prior-row read with the read door's row under a user context.
    • driver-sql (SQLite, better-sqlite3): 21 columns, 0 differences. That covers number, currency, percent, a 11-digit number, boolean true and false, date, datetime, JSON, multiselect, select, text, empty text, plus id and the seven injected system columns.
    • driver-memory: 16 columns, 0 differences.
    • Every pair was deep-equal, so no plain readable column turns empty because of serialization on either driver. The formula case stays pinned (read door evaluates it, store holds none, preview agrees with the write).
  • Write-gate probe (optional defence in depth): not added. The related-row read sits behind registerWriteGateProbe because it is SYSTEM-elevated. The stored-row read is not elevated: after this filter its image holds only values the read door served this caller unchanged, which the same caller can already read with findOne. Gating it behind the write probe would protect nothing further. It would also make a preview's verdict depend on a probe that this read does not need.

Pins added (validate-update-stored-row.test.ts now 18 cases; one new file packages/rest/src/validate-update-stored-row-representation.test.ts, 2 cases).

  • (d2) A middleware shaped like the security plugin's mask keeps the country key and replaces its value with its mask. The precondition asserts the read door serves country: '**'. The verdict for { province: 'zj', id } is deep-equal whether cn or us is stored: province invalid_option, judged as empty.
  • (d3) Control. A caller served the column unmasked gets the stored row's verdict: admitted on cn; province invalid_option on us.
  • (d4) Representation control, real driver. On driver-sql / SQLite, eight n_COLUMN fields carry a requiredWhen over number, boolean, date, datetime, JSON, multiselect, select and text. Clearing every n_COLUMN gets required on all eight, from the preview and from the by-id update alike, so each rule judged the stored value. Control: clearing the columns too changes the answer (only the number rule faults: null > 10), so an empty column would have shown.
  • File reference. The read door expands the file id (precondition). Clearing the memo the stored attachment requires gets memo required from both the preview and the write.

Ablations through scripts/ablation-replace.mjs. Each anchor hit once, and each restore was proven by blob == HEAD with git diff HEAD empty.

  • servedAsStored condition removed (back to key presence only): 1 failed / 17 passed (d2).
  • RAW_FILE_VALUES_CONTEXT_KEY removed from the visibility read: 1 failed / 17 passed (the file pin).

Sentences corrected.

  • The storedRows block comment and the validate() docblock now state the served-as-stored rule.
  • The changeset's "Read under the caller's access" paragraph now states the same rule and the measured representation. Clause-②: yes and the minor levels are unchanged.
  • protocol.zod.ts says nothing about masking, and its sentence ("a row the caller cannot read is judged on the supplied keys alone") stays true, so it is untouched.

Base. origin/main was merged (a6be68f2e6, 9 commits). The merge driver deferred content/docs/references/api/protocol.mdx. It was regenerated from the merged tree by check:generated in its regenerate mode and committed in 1f18eaddc0, which discharged the deferral. The joint diff carries the label row from #22323 plus this PR's mode text. The branch is now 3 commits behind origin/main (f66c440de9). Those commits touch none of this PR's files, so they were not merged.

Evidence on head f874bb5600 (builds and tests ran under os-verify-lock):

  • pnpm --filter @objectstack/objectql test: 391 files, 7716 passed; test:repo 5 passed.
  • pnpm --filter @objectstack/core test: 85 files, 2261 passed; test:repo 48 passed.
  • Typecheck exit 0 for objectql, core and rest, each with check:test-typecheck OK.
  • Rest: the nine import-*.test.ts files plus the new representation file: 10 files, 115 passed. This ran after building @objectstack/rest^... and driver-memory; the objectql dist carries servedAsStored.
  • Narrowed lint: eslint --no-inline-config --format json over the 8 changed .ts files gave 8 results, 0 errors, 0 warnings. The config enables no type-aware linting, so the diff cannot move a verdict on an untouched file.
  • Gates: dispatch-gates --commands derived 117 commands at f874bb5600. All 117 ran, with exit codes captured before any pipe, and all ended exit 0. Reconciliation with --ran: 117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN.
    • Two of the 117 first answered exit 3 (prerequisite not met, no measurement) inside the battery: check:skill-examples and check:dual-build-cjs-loads. Both lacked package dists in the fresh worktree. The battery's own check:type-check-debt build then supplied the dists, and both were re-run at the same head: exit 0.
    • Also run: check:adr-anchors exit 0 and check-nul-bytes exit 0.

What this changes

An update-mode validate() preview now judges what the by-id update judges: the stored row merged with the patch. Before this change, the preview judged the patch alone.

  • Engine (packages/objectql/src/engine.ts, validate()). A row that carries its id is judged against the row that id names. The id is the address every update door folds into the payload, and it is classified by the write's own resolveEngineUpdateDispatch. The stored row becomes the rules' previous, and their record is that row merged with the patch, as on the by-id branch of update(). A reference that a traversing rule reads is resolved from the same merge. That retires the named limit the old :13177 comment stated.
  • One source for the stored row. The by-id update's prior-row read is extracted into readUpdatePriorRow, and both update() and the preview call it. It is the same driver read, with the same buildDriverOptions and the same declared-columns shaping. The preview therefore judges the row as stored, not the read door's served image.
  • Read under the caller's access (assumption 4; corrected in patch round 1). Before it reads anything, the preview asks the read door (findOne under the caller's own context) whether the caller can see the row. If the read door returns nothing, the preview judges the row on the patch alone. A hidden row therefore gets exactly the verdict a missing row gets. A stored column is kept only where the read door served this caller that very value. A column the read door hid, or served transformed (a partial mask keeps the key and replaces the value), is judged as empty. A refused read propagates as raised.
  • Import runner (packages/core/src/utils/import-runner.ts). The dry run of a matched row calls validateData with { ...data, id: matched.id }. That is the same fold updateData applies to the write. Nothing new crosses the protocol, and ValidateDataRequestSchema gains no key.
  • The refusal names the real cause (cel-fault.ts, rule-validator.ts). Sometimes a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row). The refusal used to say "which this object does not declare". It now says the value was not supplied and no stored row was read. describeCelFault takes an optional isDeclaredColumn, which the four rule-validator refusal builders answer from the object's fields: field rule, option gate, script and conditional. The answer is true only for a key the source reads directly off record / previous. An undeclared key keeps the old sentence. Hook conditions pass nothing and are unchanged.
  • Spec text (cross-lane, domain:spec). The ValidateDataRequest.mode description said "update judges only the supplied keys, matching a PATCH". The ValidateDataResponse note said the preview has no prior record. This change makes both partly false, so both are reworded. No key or type changes. content/docs/references/api/protocol.mdx was regenerated with check:generated --fix.

Premise, measured on 440bed63e7 before the fix

  • Showcase object as shipped. engine.validate('showcase_cascade', { province: 'zj' }, { mode: 'update' }) refuses province with rule_violation / unevaluable. That is the option gate since objectql: evaluateOptionVisibility continues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402. The real by-id update { province: 'zj' } over a stored country: 'cn' row is admitted.
  • The card's note call. The shipped showcase_cascade declares no note field, at 440bed63e7 and at the card's 3054516ef1 alike. So { note: 'x' } throws INVALID_FIELD on the shipped object. The card's note measurement needs a fixture.
  • With a fixture note whose requiredWhen reads country. Both card calls refuse note unevaluable, and { province: 'zj' } also refuses province. The message was "The predicate reads 'country', which this object does not declare". Passing id changed nothing, because the preview read no row. The by-id update admits.

Pins, round 0 (packages/objectql/src/validate-update-stored-row.test.ts, 15 cases then, 18 now; packages/core/src/utils/import-runner-update-preview-address.test.ts, 3 cases)

  • (a) The card's two calls, { province: 'zj' } and { note: 'x' }, are admitted over a stored country: 'cn' row, and the by-id update admits the same patches. Also, a formula column that the read door evaluates is judged the way the write judges it: preview and write agree.
  • (b) Control. { country: 'us' } gets note required, and { province: 'ca' } gets province invalid_option. Preview and write give the same findings, and the store is unchanged.
  • (c) With no address, or with an id naming no row, the requiredWhen and option refusals say "its value was not supplied" and never "which this object does not declare". A validations[] script rule and a conditional rule do the same. Control: an undeclared key (contry) keeps "does not declare".
  • (d) The read scope comes from a middleware shaped like the RLS/sharing ones. The owner's preview judges the stored row. A caller who cannot read the row gets a verdict deep-equal to the one a nonexistent id gets, whether the hidden row holds cn or us. A column masked from the caller is judged as empty, with the same verdict whichever value is stored, and the control caller who can read it gets the stored row's verdict.
  • (e) The real door is runImport through ObjectStackProtocolImplementation over a kernel with ObjectQLPlugin. The update-mode dry run of a matched row admits province and note and agrees with the committed import. Control: country: 'us' fails note required in both. The core test pins that the dry run carries the matched id, that the matched id wins over an id cell (as the write's fold makes it win), and that a create row gets no id.

Round 0: red before the fix, and ablations (each from a committed state)

  • Reverse verification. The four fix files were restored to 440bed63e7 and core was rebuilt; the dist marker count fell from 1 to 0. The pin file then went 10 failed / 4 passed. Red: (a) ×2, (b) province: 'ca', (c) ×3, (d) owner and masked-column, (e) ×2. Green, as expected: (b) country: 'us', the (c) undeclared control, (d) hidden-row equality, and the (e) control. Restore leg: blobs equal HEAD, git diff HEAD empty, core rebuilt, dist marker back to 1, 14/14 green. The core address test against the base runner: 2 failed / 1 passed (the control passed).
  • Ablations through scripts/ablation-replace.mjs. Each anchor hit once, and each restore was proven by blob == HEAD with git diff HEAD empty.
    • Image = read door's row: 1 failed (the formula pin).
    • Column filter removed: 1 failed (the masked column).
    • Visibility gate bypassed: 2 failed (the hidden row and the masked column).
    • The first attempt at the image ablation was a refused no-op. Its replacement contained the anchor, so the tool refused, restored, and ran nothing. It was rerun with a non-overlapping replacement.

Tests, round 0 (superseded by the patch round 1 section above)

All runs are on head 31ca6a891a unless a line says otherwise. Builds and tests went through scripts/pm/os-verify-lock.sh.

  • Package suites. These ran on 45c2877b8f. The commits after it change only the changeset and the two pin files, and the pin files were re-run on 31ca6a891a.
    • pnpm --filter @objectstack/objectql test: 391 files, 7713 passed.
    • pnpm --filter @objectstack/objectql run test:repo: 1 file, 5 passed.
    • pnpm --filter @objectstack/core test (run on d6fb574835): 84 files, 2235 passed. test:repo: 3 files, 48 passed.
    • pnpm --filter @objectstack/spec test: 629 files, 18779 passed, 1 todo.
  • Pin files. validate-update-stored-row.test.ts 15 passed. import-runner-update-preview-address.test.ts 3 passed.
  • Typecheck. pnpm --filter @objectstack/objectql run typecheck and pnpm --filter @objectstack/core run typecheck both exit 0. Each includes check:test-typecheck OK, with the new test files compiled under each package's tsconfig.test.json.
  • Downstream consumer (the public import door through dist). I built @objectstack/rest^..., then ran the nine packages/rest/src/import-*.test.ts files (dry-run parity, integration, job, dropped-fields, row report, schema drift, run-automations agreement, historical readonly insert, unique violation): 9 files, 113 passed. The objectql and core dists were confirmed to carry the change (readUpdatePriorRow: 4 hits; the ...data, id: existing.id fold: 1 hit).
  • Spec. pnpm --filter @objectstack/spec build, then check:generated --fix. That regenerated only content/docs/references/api/protocol.mdx. A re-run reports all 15 artifacts up to date.
  • Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 117 commands at 31ca6a891a. All 117 ran with exit codes captured before any pipe, and all exit 0. Reconciliation with --ran: 117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN. An earlier run of the same battery at f4fc729b08 found two real reds, both in my test file, and fixed them:
    • check:error-code-casing: a one-line code: 'rule_violation' with no field. The assertion now names field: '_record'.
    • check:query-options-erasure: an as any on a findOne options bag. The cast was removed.
    • The same run had two prerequisite exit-3 results: check:skill-examples needed client-react built, and check:dual-build-cjs-loads needed more dists. Both measured green on the final run.
    • Also run: pnpm check:adr-anchors exit 0, and node scripts/check-nul-bytes.mjs exit 0.
  • Lint, narrowed (a measurement, not a skip). pnpm exec eslint --no-inline-config --format json over the 7 changed .ts files returned 7 results, 0 errors and 0 warnings. Those files are inside eslint.config.mjs's packages/**/*.{ts,tsx,mts,cts} and **/*.{ts,...} populations. The config enables no type-aware linting (no parserOptions.project / projectService anywhere), so this diff cannot move a verdict on any file it does not touch. The repo-wide pnpm lint is CI's.

Acceptance notes

  • Preview/write drift, same family, measured on this branch (not fixed here). validate() binds no master-detail parent header, in either mode. On a detail object whose field carries requiredWhen: "parent.status == 'sent'", the preview refuses with rule_violation / unevaluable (unbound parent), both for an insert under a draft header and for an update of a stored line. The real insert and the real by-id update admit both. The import dry run reaches this through previewVerdict -> validateData. Reported for the seat to file; not filed here.
  • Named limit kept, as dispatched. The update preview still runs no readonlyWhen or primary-key strip. It now holds the stored row those strips would judge, so closing this limit is possible, but it is out of this card's scope. The ValidateDataResponse note keeps stating it.
  • Observed, not filed. A requiredWhen that reads a formula column (record.doubled > 100) faults with a null overload on every by-id update. The write's prior row is the stored row, which holds no formula value. The preview now agrees with the write here, and a pin holds that agreement. Whether os validate refuses such a predicate was not measured.
  • Observed, not filed. ValidateDataIssueSchema declares { field, code, message }. The engine's findings, which validateData relays, also carry constraint, and an option refusal carries value. The pin file reads constraint through a typed accessor for that reason.
  • New read surface. The update preview's visibility read goes through findOne, so it fires the object's beforeFind / afterFind hooks once per addressed row. The related-row read already did the same through find. No write hook runs.
  • The card's note call. It needed a fixture field: the shipped showcase_cascade declares no note. The shipped object's own province pick reproduces the defect.
  • Base (round 0). origin/main was merged once (b7c02e713f). Patch round 1 merged it again; see that section.
  • Cross-lane. packages/spec/src/api/protocol.zod.ts is edited: description text and a JSDoc note only, no key or type. The changeset bumps @objectstack/spec at minor along with @objectstack/objectql and @objectstack/core.

Generated by Claude Code

claude added 10 commits October 9, 2026 09:47
…th the patch (wip)

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
…he write's own prior-row read

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
… in the stored-row pins

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

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/objectql, @objectstack/spec, touching 18 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/api/wire-format.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/data-modeling/fields.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/data-modeling/import-mappings.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/protocol/objectql/state-machine.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol runImport — its route source's handler names it))

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

  • content/docs/releases/v12.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/releases/v17/17-6.mdx (via runImport (symbol, a top-level function), /:object/import (route, bridged from symbol runImport — its route source's handler names it))

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 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 — 144 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 f66c440de93c1733683a20b2a107ac8b9c98fc19 → packageMentionDocs.

Which tree this was computed on

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

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f66c440de93c1733683a20b2a107ac8b9c98fc19 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 31ca6a891aaf78239f19b98b01eea266e2626ade
Local-runs: none

Inputs: card #22445 (body; triage 6077755047; claim 6078256149; os-dev-report 6079914508), PR #22471 (body, 9-file list, the one bot comment 6079883051, zero reviews), the net diff of the head against its merge base with origin/main (2b61f2d9d6; 730 added / 43 deleted), and the check-runs on the head. origin/main source was read with git show / git grep to judge consumers; nothing was built, run or re-run. Reading time: 2026-10-09T11:41Z.

Check-runs on the head, as read at 2026-10-09T11:38Z (33 runs): 26 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 5 in_progress — Test Core (2/6), (3/6), (4/6), (5/6) and Type Check · workspace. Required contexts: Lint & Repo Gates success; Build Core success; Dogfood Regression Gate success (all three shards); Temporal Conformance (live PG + MySQL) success; Governed Surface Queue Guard success; Test Core 2 of 6 shards success, 4 in progress; TypeScript Type Check is the summary job (needs: the four Type Check · sub-jobs in lint.yml) and had not yet appeared because Type Check · workspace was still running. Type Check · source gates (success) is the job that carries check:docs, check:authorable-surface and check:generated --reconcile-only, so the spec regeneration is CI-confirmed; check:api-surface sits in Type Check · consumer gates (success). An in-progress run is recorded as such; none is read as a pass.

① Derived judgments

Accept-set and public-surface changes the diff implies, each named right or wrong.

  1. The update-mode preview's accept set widens, exactly where the by-id update admits — RIGHT. A row whose id is a truthy scalar (resolveEngineUpdateDispatch(submitted, undefined) answers by-id; a reject reads nothing, so a row with no usable address is judged on the patch alone, as before) is judged with previous: stored and record: patch, which is the by-id branch's own call shape (engine.ts:15735 on main: evaluateValidationRules(updateSchema, data, 'update', { previous: priorRecord, … })). evaluateValidationRules builds the merge and materialises declared columns only when a prior record is in hand (rule-validator.ts:3270-3288), so preview and write judge one record. Pins (a) and (b) hold both directions; (e) holds it through the protocol and runImport. The widening is confined to rows that carry an address; required and value checks still read the supplied keys.

  2. readUpdatePriorRow is a behaviour-identical extraction of the by-id update's prior read — RIGHT. On main the write's updateSchema is this._registry.getObject(object) (:14769), its driver is this.getDriver(object), and the options bag is buildDriverOptions(object, context, hookContext.input.options); the new method takes the same three and the preview passes base undefined. Same AST, same declared-columns shaping.

  3. The read door decides whether there is a row, and a refused read propagates — RIGHT. The preview asks findOne under the caller's own context before the raw read; a row the read door does not return is judged as a missing row, and pin (d) holds that verdict deep-equal whatever the hidden row holds. Not catching the read is the correct choice under the repo's read-seam invention rule (a swallowed read that answers "no row" is an invented answer on a storage seam in packages/objectql/src). On the import path the throw lands in the per-row try (import-runner.ts:973 / :1086 on main), so one row fails, not the run; and the match read (findExisting through findArgsBase) ran under the same context, so a refusal there is unreachable in practice.

  4. The import dry run of a matched row passes { ...data, id: existing.id } — RIGHT. It is the fold the protocol's updateData applies to the write, existing is the record target is bound from in the write branch, nothing new crosses ValidateDataRequestSchema, and the core test pins the fold, the id precedence and the insert control.

  5. Related-row resolution reads FKs off the merged view — RIGHT. resolvePredicateRelated(schema, judgedViews, …) and previewRelatedForRow(judgedViews[i]) key on the same object, so a FK the patch omits resolves from the stored row, as updateView does on the write; the related read stays behind the mayWrite probe as before.

  6. The refusal text for a declared column the judged record lacks — RIGHT, and not a public-surface change. CelFaultSubject.isDeclaredColumn is optional on a module-internal interface (cel-fault.ts is not on objectql's index.ts); declaredColumnRead answers true only for a key the object declares AND the CEL source reads directly off record / previous (readsRecordColumn, already on main), so a key reached through a reference, a computed key or a partly-filled root keeps the old sentence; the contry control pins the undeclared case. The four refusal builders are the right call sites; hook conditions pass nothing and are unchanged.

  7. A new read surface: one findOne per addressed row, firing that object's read hooks — RIGHT, and disclosed in the PR body, the engine docblock and the inline comment that used to say "nothing is read". No write hook runs.

  8. "A column the caller may not read is judged as empty" — the column filter hasOwnProperty(visible, key) delivers this only for a HIDDEN column, not for a PARTIALLY MASKED one — WRONG. On main, plugin-security's read mask (field-masker.ts maskResults / maskRecord, applied to find / findOne results at security-plugin.ts:9970-9974) DELETES a non-readable field only when no masking rule names it (hiddenFields = !perm.readable && !(field in rules)); a field with a partial-masking rule is REPLACED with its masked value and keeps its key, whether or not the caller may read it. So the read door's visible carries that key, the preview overlays stored[key] = prior[key] — the RAW stored value — and the rules (requiredWhen, option visibleWhen, validations[] script and conditional) evaluate over it; their verdict is returned to the caller. That is a predicate over a masked column, the exact thing the plugin's step 2.9 guard refuses on a query, in its own words: "a field this caller sees PARTIALLY MASKED is just as probe-able as a hidden one — both are folded in as non-queryable" (security-plugin.ts:4244-4247), and getQueryableFields (:6144-6160) excludes masked fields for the same reason. The by-id write judges the raw prior row too, but behind its write gates; the preview runs none, so this opens a 1-bit-per-rule oracle on a masked column's stored value to any caller who can READ the row (not write it) — a new disclosure channel, bounded by the object's own rules. It contradicts the diff's own invariant ("the verdict never depends on a value the caller could not have read", engine.ts storedRows block) and the changeset's "a column the caller may not read is judged as empty … A preview discloses nothing about a row or column the caller could not read" — a sentence the runtime does not deliver (Prime Directive chore: version packages #10). Pin (d)'s hideCountryFrom deletes the key, so it covers the hidden shape only; the masked shape has no pin and was not measured. What a corrected head owes: the invariant stated in the diff, pinned the way (d) pins it — for a caller served country through a partial-masking rule, the verdict of { province: 'zj', id } is identical whichever raw country is stored — plus a changeset sentence that is true of the mechanism shipped. Two shapes that would satisfy it, for the dev to choose between (not a ruling): gate the stored-row read behind the mayWrite probe, as the related read already is, so only a caller the write itself would answer gets the raw-judged verdict; or treat a column the read door served transformed as absent, the way a hidden column is. Either way the sentence and the mechanism must agree.

  9. Spec text (ValidateDataRequest.mode description, ValidateDataResponse JSDoc) — RIGHT as far as it goes, and inside the claim's declared conditional surface (text only, no key, no type); content/docs/references/api/protocol.mdx is the generator's output and CI's Type Check · source gates confirms it. The mode description's last sentence ("a row the caller cannot read is judged on the supplied keys alone") is true; it says nothing about a masked column, so it needs no change — the false sentence is in the changeset and the engine comment, not here.

  10. The master-detail parent header is still unbound in validate() (the by-id update passes roWhenParent / roWhenPreviousParent; the preview passes neither) — RIGHT to leave out of this diff; it is the dev's class-a out-of-scope finding, handled in ③.

Reach of the widening, measured on main: engine.validate(), the protocol's validateData relay (protocol.ts:13608) and the import dry run through it are the only non-test update-mode callers; no REST route serves validateData (route ledger), so no wire consumer changes shape.

Join: at my read the branch is 7 commits behind origin/main (9af0005d55); the files main gained since the merge base and the PR's 9 files are disjoint (comm over the two --name-only lists is empty). The queue's merge ref judges the join.

② Semver level

Clause-②: yes is declared in the PR body and in .changeset/22445-update-preview-stored-row.md, with no (widening) / (narrowing) arm — zero arms is within the closed pair's "at most one". The widening is real (rows the preview refused and the write admits are now admitted), so the floor is minor; the changeset bumps @objectstack/objectql, @objectstack/core and @objectstack/spec at minor. Nothing is removed, renamed or narrowed — no key, no export (CelFaultSubject is internal), no error code — so no ADR-0087 disposition marker is owed and check:adr-0087-registration / check:changeset-no-major have nothing to read; Check Changeset is success on the head. Level: RIGHT. Spec at minor for a description-only edit is covered by the one declaration and is not wrong. The closing paragraph that corrects the pending #22402 entry's "reads no stored row" sentence, rather than editing that other PR's changeset, is acceptable: both compile into one release's notes. What is NOT right is the changeset BODY: its "judged as empty … discloses nothing about a row or column the caller could not read" claim is false for a partially-masked column (①-8); CHANGELOG.md is what an upgrading agent greps, so the sentence must match the mechanism before this ships. The level stands; the text does not.

③ Boundary flags

Dev deviations (os-dev-report 6079914508), each answered:

  • Cross-lane edit of packages/spec/src/api/protocol.zod.ts — ANSWERED, accepted: the claim 6078256149 declared this exact surface conditionally ("an optional key naming the stored record, and the sentences this change makes false"); the dev took the narrower half only (sentences, no key). The domain:spec note is the PM's to file, as the report says — seat action, not a blocker.
  • Route refinement: two reads per row, readUpdatePriorRow extracted — ANSWERED: the extraction is right (①-2); the second read's column filter is where ①-8 lives. Not accepted as shipped.
  • Base: one origin/main merge, now behind — ANSWERED, accepted: no file overlap at my read; the queue judges the join.
  • Package suites run at earlier heads, pins re-run at the head — ANSWERED, accepted: the later commits touch the changeset and the two pin files only; CI on the head carries the suites (Test Core 2–5 still in progress at my read).
  • Stray /tests.pid at the container filesystem root — ESCALATED as housekeeping: outside the repository, nothing in the diff; whoever holds that container deletes it.

open_questions: none declared — nothing to answer.

out_of_scope_findings, each answered or escalated:

  • Class a, named producer: validate() binds no master-detail parent in either mode, so a requiredWhen reading parent.status refuses the preview (unevaluable) where the insert and the by-id update admit — same preview/write family as this card. The report says "for the seat to file; not filed here". ESCALATED: Prime Directive chore: version packages #10 wants a card for a reproducible defect with a named producer; this record is read-only, so the owning seat files it (dedupe words are in the report).
  • readonlyWhen / primary-key strip still not run on the preview — ANSWERED: the named limit the dispatch kept out of scope; ValidateDataResponse still states it. Noted, nothing owed here.
  • A requiredWhen over a formula column faults on every by-id update (the prior row holds no formula value); the preview now agrees and a pin holds the agreement — ANSWERED: noted; the agreement is the right outcome for this card. Whether the write's own fault on a formula read is a defect is a separate question the seat may want to card.
  • ValidateDataIssueSchema declares { field, code, message } while the relayed engine findings also carry constraint and (on an option refusal) value — ESCALATED for the seat to judge: a response contract narrower than what the runtime sends is a contract-vs-runtime drift on the protocol surface (domain:spec), card-worthy under the same directive.

PR-side flags: the Docs Drift Check (6079883051) named five hand-written docs via runImport; I read the import dry-run paragraph it points at (import-mappings.mdx:176-177, "the verdict comes from the engine's own write-path validation") and it stays true — no doc edit owed. The required-context name TypeScript Type Check is the lint.yml summary job and appears once its four sub-jobs finish — not a detached gate.

Implemented-by: claude/issue-22445-update-preview-stored-row
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: FAIL — on ①-8 alone: the preview judges the raw stored value of a column the read door served partially masked, which is a predicate over a masked column (refused by the repo's own anti-filter-oracle rule) and contradicts the diff's stated invariant and the changeset's sentence; everything else judged above is right. A re-review is owed on the head that closes it (pin + mechanism + changeset text), and the landing also waits for the in-progress check-runs to conclude.


Generated by Claude Code

claude added 3 commits October 9, 2026 11:50
…d transformed as empty

A partially masked column keeps its key in the read door's image, so a
key-presence filter overlaid its raw stored value. Keep a stored column only
where the read door served this caller that very value.

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
…s as its stored value

Also states the served-as-stored column rule in the changeset.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f874bb5600b0074d4670aca609699fc90afd519f
Local-runs: none

Inputs: card #22445 (body; triage 6077755047; claim 6078256149; round-0 report 6079914508; patch-round report 6081033502), PR #22471 (body with its "Patch round 1" section, the 10-file list, the bot comment 6079883051, the earlier record 6080151175 on head 31ca6a891a, zero reviews), the net diff of the head against its merge base with origin/main (c512c255c5; 962 added / 43 deleted, 10 files, equal to the PR's own file list), and the check-runs on the head. origin/main source was read with git show / git grep to judge consumers (field-masker.ts, security-plugin.ts, engine.ts's findOne, engine-update-dispatch.ts, rule-validator.ts); nothing was built, run or re-run. This head is judged on its own net diff; the earlier record is an input, not a template. Reading time: 2026-10-09T13:03Z.

Check-runs on the head (42 runs, all completed): 38 success, 4 skipped, 0 failure, 0 in progress. The seven required contexts: Lint & Repo Gates success; TypeScript Type Check success (its four Type Check · sub-jobs — source gates, consumer gates, debt ledger, workspace — all success, so check:docs, check:authorable-surface, check:generated --reconcile-only and check:api-surface are CI-confirmed); Test Core success (all six shards); Dogfood Regression Gate success (all three shards); Build Core success; Temporal Conformance (live PG + MySQL) success; Governed Surface Queue Guard success. The four skipped: Console Pin Gate (no console or pin path in the diff) and Packed-tarball smoke (opt-in), neither required; and the second Auto Label / Check PR Size instances, re-triggered by the PR-body edit and skipped while their first instances on the same head are success. Check Changeset success (twice, same reason). The 5 runs the earlier record read as in progress have all concluded success.

① Derived judgments

Accept-set and public-surface changes the diff implies, each named right or wrong. Items 1–7 re-verified on this head's diff; 8–15 are this round's.

  1. The update-mode preview's accept set widens exactly where the by-id update admits — RIGHT. A row whose id is a truthy scalar is classified by-id by the write's own resolveEngineUpdateDispatch(submitted, undefined) (engine-update-dispatch.ts:438-446 on main: asScalarId(data.id), then truthiness); any other shape reads nothing and is judged on the patch alone, as before. For a by-id row the rules get previous: stored and record: patch — the by-id branch's own call shape — and evaluateValidationRules materialises every declared column once a prior record is in hand (rule-validator.ts:3270-3288 on main), so preview and write judge one record. storedRows is built over submittedRows and judgedViews over rows, which is rawRows.slice() on update, index-aligned by construction. Pins (a), (b) hold both directions; (e) holds it through the protocol and runImport.

  2. readUpdatePriorRow is a behaviour-identical extraction of the by-id update's prior read — RIGHT. Same QueryAST ({ object, where: { id }, limit: 1 }), same buildDriverOptions(object, context, base), same withDeclaredColumnsOnly(…, declaredColumnSet(schema)); the by-id branch now calls it with hookContext.input.options as base and the preview with base undefined. Private method, no surface.

  3. The read door decides whether there is a row; a refused read propagates — RIGHT. The preview asks this.findOne under the caller's own context before the raw read; a row the read door does not return is a missing row (pin (d), deep-equal verdict whatever the hidden row holds). Not catching the read is the correct choice under the read-seam invention rule for packages/objectql/src. On the import path a throw lands in the per-row try so one row fails, not the run.

  4. The import dry run of a matched row passes { ...data, id: existing.id } — RIGHT. On the head willUpdate is existing && typeof existing === 'object', so the fold reads a record, never a 'none' / 'blank' / 'ambiguous' marker; it is the fold the protocol's updateData applies to the write; ValidateDataRequestSchema gains no key; the core test pins the fold, the id precedence and the insert control.

  5. Related-row resolution reads FKs off the merged view — RIGHT, and on this head it depends on an FK column surviving the new comparison (item 8). Verified on main's findOne: its post-driver steps are the formula plan, related-record expansion ONLY when the query carries expand, file-reference resolution, afterFind, maskSecretFields and stripSearchCompanionFromRead — a stored reference id is served unchanged by default, so it compares equal and is kept; resolvePredicateRelated(schema, judgedViews, …) and previewRelatedForRow(judgedViews[i]) key on that merge. The related read stays behind the mayWrite probe as before.

  6. The refusal text for a declared column the judged record lacks — RIGHT, not a public-surface change. CelFaultSubject.isDeclaredColumn is optional on a module-internal interface (cel-fault.ts is not on objectql's index.ts); declaredColumnRead answers true only for a key the object declares AND the CEL source reads directly off record / previous; the four refusal builders are the call sites; hook conditions pass nothing. With a stored row in hand a withheld column is materialised to null, so the new "no stored row was read" sentence fires only where no row was read — pin (d) shows the hidden-column case answers a clean invalid_option, never this sentence.

  7. A new read surface: one findOne plus one raw prior read per addressed row, firing the object's read hooks — RIGHT, disclosed in the PR body, the validate() docblock and the inline comment. No write hook runs.

  8. The earlier record's ①-8 (a partially masked column judged on its raw stored value) is CLOSED — RIGHT. The column filter is now hasOwnProperty(served, key) && servedAsStored(served[key], prior[key]), a module-private structural comparison (Object.is; Date by time; arrays and plain objects recursively; any other object kind or nesting past 16 answers false). Walked against plugin-security on main: a hidden field (maskRecord deletes the key: hiddenFields = not readable and no rule) is not kept; a partial-masking rule keeps the key and replaces the value with maskFieldValue(...), which is not structurally equal to the stored value, so it is not kept; maskSecretFields serves SECRET_MASK, not kept; an internal: true column is omitted by the read door, not kept; a read hook's rewrite, not kept. What is kept is exactly the set of values the caller already received in full from findOne, so the verdict is a function of the patch, the declared rules and values the caller already holds — no bit of a withheld value reaches it. The one case where a masked column IS kept, a partial rule whose output equals its input on a value too short to mask, is a value the caller already sees whole. The comparison fails closed in the direction the comment states. Pin (d2) holds the masked shape with the precondition asserted (country: '**' served) and the verdict deep-equal across stored cn / us; (d3) is the control. Of the two shapes the earlier record offered, the stricter was taken; the mayWrite-probe shape would have left a residual channel (the probe's arms are a subset of the write's gates), so not taking it is right.

  9. The visibility read passes RAW_FILE_VALUES_CONTEXT_KEY — RIGHT. On main its only runtime reader is resolveFileReferences (engine.ts:11795), which skips the expansion step and nothing else: middleware, read hooks, the secret mask and the internal strip all still run on that read. It is the spec-declared opt-out for "a caller whose subject is the STORED form", which this read is; service-storage's three system callers use it the same way. Without it every readable file reference would compare as an expanded object against a stored id and be judged empty; the file pin holds it and the dev's ablation measured it load-bearing.

  10. The accept set for a caller the read door serves a column withheld or transformed — named as its own judgment because it is the consequence of item 8: for that caller the preview judges the column as null where the write judges the raw value. Before this PR the same rule faulted and refused every such row; now it evaluates cleanly over null, so for that caller class alone the preview can admit a row the write refuses (a requiredWhen over a masked us with no note supplied) or refuse one the write admits (a numeric comparison over null faults — pin (d4)'s control shows null greater-than 10 has no overload). RIGHT, as the only non-disclosing shape: the write remains the gate (a dry-run all-clear is reported by the real import's row report, never a silent loss); it is disclosed in the engine comment ("that direction can only make the preview refuse or admit on less than the write knows") and in the changeset's "judged as empty" sentence. It leaves one overbroad sentence in the changeset, taken up in ②.

  11. The representation control on a real driver (packages/rest/src/validate-update-stored-row-representation.test.ts) — RIGHT as a test-only addition: packages/rest already declares @objectstack/driver-sql and @objectstack/objectql as devDependencies, 79 of its tests on main use the better-sqlite3 client the same way, and the file publishes nothing. It pins eight column types (number, boolean, date, datetime, json, multiselect, select, text) reaching the rules as stored from both the preview and the by-id update, with a control that would show an empty column. The objectql pin file's @objectstack/core / @objectstack/metadata-protocol imports are declared dependencies of objectql with 47 / 96 precedent files on main.

  12. Spec text (ValidateDataRequest.mode, ValidateDataResponse JSDoc) — RIGHT and untouched this round; the mode sentence "a row the caller cannot read is judged on the supplied keys alone" stays true. content/docs/references/api/protocol.mdx is the generator's output on the merged tree (the dev discharged the merge driver's deferral by regeneration in 1f18eaddc0) and Type Check · source gates confirms it. No other sentence on the head says the update preview reads nothing (grep over content/docs, packages/spec/src, packages/metadata-protocol/src, skills): the remaining "no prior record" hits are about readonlyWhen on insert, which is a different and still-true statement.

  13. Docblock placement — WRONG in shape, harmless in effect: the one-line docblock "Merge read-path execution context from the query and the trailing options." (engine.ts:3210 on the head) now sits above servedAsStored's own docblock, so mergeReadContext (:3250) has lost its doc and servedAsStored carries a stray second comment. No gate reads it, no behaviour; the dev moves one line on the next touch. Does not move the verdict.

  14. Public surface — RIGHT: servedAsStored is a module-private function, readUpdatePriorRow is private, CelFaultSubject is internal; no export, key, option or error code is added, removed or renamed; check:api-surface sits in Type Check · consumer gates (success).

  15. The master-detail parent header is still unbound in validate() — RIGHT to leave out of this diff; the dev's class-a out-of-scope finding, handled in ③.

Reach, measured on main: engine.validate(), the protocol's validateData relay and the import dry run through it remain the only non-test update-mode callers; no REST route serves validateData, so no wire consumer changes shape.

Join: at my read the branch is 4 commits behind origin/main (dee7692f0b); the 38 files main gained since the merge base and the PR's 10 files are disjoint (comm over the two --name-only lists is empty); GitHub reports the PR mergeable and clean. The queue's merge ref judges the join.

② Semver level

Clause-②: yes is declared in the PR body and in .changeset/22445-update-preview-stored-row.md, with no (widening) / (narrowing) arm — zero arms is within the closed pair's "at most one". The widening is real (rows the preview refused and the write admits are now admitted), so the floor is minor; the changeset bumps @objectstack/objectql, @objectstack/core and @objectstack/spec at minor. Nothing is removed, renamed or narrowed — no key, no export, no error code — so no ADR-0087 disposition marker is owed; Check Changeset is success on the head and check:adr-0087-registration, check:changeset-no-major and check:empty-changeset ride in Lint & Repo Gates (success). @objectstack/rest publishes nothing from this diff (one test file), so no changeset line is owed for it. Level: RIGHT.

Text: the patch round rewrote the "Read under the caller's access" paragraph to state the served-as-stored rule and the measured representation, and the round-0 sentence that was false of the mechanism ("discloses nothing about a row or column the caller could not read") is gone; what the paragraph now says is what the engine does (①-8, ①-9). One sentence remains overbroad: the "Unchanged" bullet's "A merge that really violates a rule is refused, as the write refuses it" — exact for a caller the read door serves in full, and for the merge the preview holds; for a caller served a withheld column the preview judges null and may admit where the write refuses (①-10). The neighbouring bullet states that rule, so the entry read whole is true; the sentence alone is not. This is a precision note, not a capability the runtime does not deliver on the order of round 0; the seat may tighten it on the next changeset touch ("…as the write refuses it, for a caller the read door serves in full"). It does not move the verdict.

③ Boundary flags

Patch-round deviations (os-dev-report 6081033502), each answered:

  • Two battery gates (check:skill-examples, check:dual-build-cjs-loads) first exited 3, re-run exit 0 at the same head — ANSWERED: the local battery is not an input to this record; CI carries both families on the head (Lint & Repo Gates, the four Type Check · jobs), all success.
  • A new test file in packages/rest, outside the claim's listed surface — ANSWERED, accepted: test-only, publishes nothing, its devDependencies are already declared and the pattern has 79 precedent files; the earlier record's ①-8 asked for a representation control on a real driver and objectql cannot depend on one (①-11).
  • A throwaway driver-memory probe, deleted and never committed — ANSWERED: the net diff holds exactly the 10 listed files and no such file.
  • The branch is behind origin/main after this round's merge; the later commits were not merged — ANSWERED, accepted: no file overlap at my read (4 commits, 38 files, disjoint); the queue's merge ref judges the join.
  • The round-0 worktree was recreated on the branch and removed again — housekeeping; nothing owed.

Round-0 deviations (6079914508) were answered in record 6080151175 and stand: the cross-lane protocol.zod.ts text edit is inside the claim's declared conditional surface and the domain:spec note is the PM's; the route refinement is right (①-2) and its column filter is now the one ①-8 asked for; the stray /tests.pid at the container root remains that container's holder's to delete.

open_questions: none declared — nothing to answer.

out_of_scope_findings: "unchanged from round 0, nothing new found" — each still answered or escalated:

  • Class a, named producer: validate() binds no master-detail parent in either mode, so a requiredWhen reading parent.status refuses the preview where the insert and the by-id update admit — STILL ESCALATED: the earlier record escalated it for the owning seat to file; the inputs to this record carry no card number for it, so it is owed until one exists (dedupe words in 6079914508).
  • readonlyWhen / primary-key strip still not run on the preview — ANSWERED: the named limit the dispatch kept out of scope; ValidateDataResponse still states it and the changeset's "Unchanged" bullet repeats it.
  • A requiredWhen over a formula column faults on every by-id update — ANSWERED: noted; the preview now agrees with the write and a pin holds the agreement; whether the write's own fault is a defect is the seat's to card.
  • ValidateDataIssueSchema narrower than the relayed findings (constraint, value) — STILL ESCALATED to the seat (domain:spec), as in round 0.

PR-side flags: the Docs Drift Check (6079883051) was recomputed on the new merge ref and lists the same five hand-written docs via runImport; the diff adds no route or exported symbol since round 0, so the round-0 reading stands and no doc edit is owed. The PR is draft with no auto-merge; its 10 files touch no governed surface; head repo equals base repo; 1,005 changed lines. The seat lands it through the queue once the branch is readied; nothing in this record is a condition on that beyond the check-runs already green.

Implemented-by: claude/issue-22445-update-preview-stored-row
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: PASS — ①-8 of the earlier record is closed by a mechanism that keeps only values the caller already received in full, pinned on the masked shape with its precondition asserted and controlled on a real driver; every accept-set and surface change the diff implies is named right above, the one cosmetic wrong (①-13) and the one overbroad changeset sentence (②) move nothing; the level is right; every dev flag is answered and the two standing escalations are the seat's.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 13:06
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