Skip to content

fix(objectql)!: a system write's readonly value is judged for its shape — a seed's malformed readonly datetime is refused, never stored (#21663) - #21695

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21663-readonly-shape-check
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21663-readonly-shape-check

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21663
Clause-②: no (narrowing)

What this changes

A system writer is exempt from the readonly strip, never from the value-shape check (triage's ruling on the card, comment 5975978206).

The static readonly strip drops a non-system caller's readonly value and exempts a system write (seed replay, migration, isSystem plugin code, a hook's stamp). The record validator skipped every readonly field outright, on the premise that the strip had already removed anything a caller sent. That premise is false for exactly the writers the strip exempts, so under isSystem a malformed readonly value reached the driver unjudged.

After this PR:

  • The strip is untouched. It keeps its system exemption, and a non-system caller's readonly value is still dropped, never refused.
  • The value the exemption keeps is judged for its SHAPE wherever the payload is final. A malformed value is refused with VALIDATION_FAILED (400 at the HTTP boundary), with the same field code and the same sentence a non-readonly field gets (Run At must be a valid datetime (ISO-8601)). A seed counts the row as a seed error.
  • ⛔ No silent coercion. Nothing malformed is rewritten into something else.

Where the fix lives (the order's H1 file location did not hold)

The dispatch expected the branch in packages/objectql/src/validation/rule-validator.ts. Measured at 72f3c74d60: the strip lives there (stripReadonlyFields), but the shape check and its readonly skip live in packages/objectql/src/validation/record-validator.ts (validateRecord, if (def.system || def.readonly) continue on both walks). The engine (packages/objectql/src/engine.ts) runs the strip and the validator at different points:

path order at 72f3c74d60
insert strip, then validateRecord
dry run (ObjectQL.validate) strips, then validateRecord
update, by id and by predicate validateRecord, then the strip

So the fix lands in the producer's own file, with the engine choosing the scope at each seam:

  • record-validator.ts: a ReadonlyValueScope ('skip' | 'include' | 'only') and a module-internal validateRecordInScope. The published validateRecord keeps its signature and behaviour byte for byte, so nothing on the package's public surface widens (the claim's Clause-② line holds).
  • engine.ts: insert and dry run judge with 'include' (post-strip). Both update paths keep their first call at 'skip' and add a second pass at 'only' right after assertNoStrictDrops(), where the payload is final.
  • Why not judge readonly values in the update path's first call: that call runs before the strip, so a readonly value there may be a caller's that the strip is about to drop. A whole-record write-back that echoes a legacy malformed stored value would turn from a save into a refusal. Pin 3 holds this.
  • rule-validator.ts: docs only. The strip's docblock now says a system write skips the strip and nothing else.

The boundary: shape, never a constraint

A readonly value reaches the type's shape arms only:

  • refused: a date / datetime / time the platform does not read; a non-number on a number-typed field; a non-boolean; a non-array on a multi-value field; a filter-operator object; and the ADR-0104 reference / media / structured-JSON shape under the object's own posture (warn-first, exactly as on a non-readonly field).
  • not checked, as before: option membership, maxLength / minLength, valueDomain, min / max / scale / precision, the email / url / phone formats, and required.

Option membership is the load-bearing exclusion. sys_activity.type is a readonly select whose options are the built-in set of an open vocabulary. The maintainer ruling recorded at commit 88b9d749a binds that an author-contributed value is stored, and its object file says "Do not fix this by enforcing the enum on system-owned writes". The email / url / phone formats stay out because the spec's stored shape for those types (valueSchemaFor) is a plain string. Readonly url fields that platform code writes (sys_activity.url, sys_activity.actor_avatar_url, sys_organization.logo) are why that matters.

For the same reason "judged" equals "stored": a numeric string on a readonly number field is now written as its number (normalizeNumericStringValues, at the door, ahead of the caller snapshot, so the strip still drops a non-system caller's key), and a lone scalar on a readonly multi-value field is wrapped post-strip, as on any other field.

H2: which shape checks a system write skipped (measured)

A throwaway probe (deleted, never committed) inserted one malformed value per type into a readonly field and into its non-readonly twin.

field (malformed value) isSystem, readonly, at 72f3c74d60 isSystem, readonly, after isSystem, non-readonly (unchanged) non-system, readonly (unchanged)
datetime 'yesterday' stored refused invalid_date refused dropped
datetime, raw cel envelope stored refused invalid_date refused dropped
date 'yesterday' stored refused invalid_date refused dropped
time 'noon' stored refused invalid_time refused dropped
number / currency / percent 'abc' stored refused invalid_number refused dropped
boolean 'maybe' stored refused invalid_boolean refused dropped
multiselect, an object stored refused invalid_type refused dropped
text, { $in: [...] } stored refused invalid_type refused dropped
number max: 5, value 9 stored stored (constraint) refused dropped
select, undeclared option stored stored (constraint) refused dropped
text maxLength: 3, 6 chars stored stored (constraint) refused dropped
email / url / phone, malformed stored stored (format) refused dropped
lookup, cel envelope stored stored with the ADR-0104 warning (warn-first) stored with the warning dropped
location 'nowhere' stored stored with the ADR-0104 warning (warn-first) stored with the warning dropped

H3, H4, H5

  • H3, the seed path: measured through the real SeedLoaderService (pins 1 and 2). 'yesterday' on a readonly datetime is refused and counted (summary.totalErrored), with the non-readonly sentence, on the fresh-boot insert and on the replay update. A valid ISO value, a cel value the loader evaluates, and an authored created_at are kept. That last case pairs with the arm A seed row's explicit created_at is overwritten with the boot instant on INSERT (seed context sets no preserveAudit), yet written on the upsert UPDATE of a later boot — seeds cannot backdate creation time consistently #21646 landed.
  • H4, the seeders that skip resolveSeedRecord: AppPlugin's two fallback inserts (packages/runtime/src/app-plugin.ts, the no-metadata-service branch and the loader-threw branch) and @objectstack/verify's seed() (packages/verify/src/handle.ts). A raw cel envelope on a readonly datetime is now refused on their call shapes (single-row and array insert under SEED_WRITE_EXECUTION_CONTEXT, pinned). No example app or test newly fails. runtime (4554 tests) and verify (131) are green. The only readonly field seeded with cel in examples/ is created_at, in 10 app-showcase task rows, and every one of those rows also seeds a non-readonly due_date with cel. So on the fallback path those rows were already refused before this change, and on the normal path the loader evaluates them. No cross-lane fix is needed for this change. The fallback's pre-existing warn-level per-row loss is in the Acceptance notes.
  • H5, other system writers: measured through the platform's own suites. None writes a malformed readonly value. Green: plugin-audit 621, plugin-pinyin-search 21, plugin-security 3527, plugin-auth 2494, plugin-approvals 875, service-automation 2098, metadata-protocol 3463, runtime 4554, verify 131. Inside objectql, two fixtures turned red and were re-judged, not relaxed:
    • engine-insert-static-readonly-strip.test.ts: an isSystem case used the placeholder 'x' in a readonly datetime, in a test about strictReadonlyWrites. It is respelled to a valid instant, like its isSystem sibling.
    • record-validator.number-value.test.ts: it pinned "the numeric normalizer skips a readonly field". The number arm now judges a readonly value, so by the normalizer's own invariant (what the arm judges is what the driver stores) the readonly field moves to the rewritten side.

Pins

packages/objectql/src/seed-readonly-value-shape.test.ts, on the real kernel (ObjectKernel + ObjectQLPlugin) and the real SeedLoaderService. Each refusal asserts code and status (ADR-0112) through resolveThrownHttpError, the boundary's own reading. The engine's ValidationError carries no status by design.

  1. 'yesterday' on a readonly datetime in a seed is refused and counted, with the same sentence the non-readonly twin gets. The same holds on the replay, on all four write seams (insert, update by id, update by predicate, dry run) as VALIDATION_FAILED / 400 with the non-readonly field envelope, for a raw cel envelope, and for a malformed authored created_at.
  2. A valid ISO value on a readonly field under the seed context is kept: authored, evaluated from cel, and on created_at, on insert and on replay.
  3. The non-readonly path is unchanged. A non-system caller's readonly value is still dropped, never refused, on insert and on a whole-record write-back echoing a legacy malformed value. A readonly undeclared option and an out-of-bound number are stored, while the non-readonly twin refuses both.

Reverse verification (committed first, at 196b217829). The split was reverted at its one predicate in record-validator.ts, through scripts/ablation-replace.mjs under a shell trap: anchor if (def.readonly === true) return scope !== 'skip'; went from 1 to 0 hits, the replacement return false from 0 to 1, and the blob from d57cbd3078 to 3768d5668f. Result: Tests 4 failed | 4 passed (8). The four red tests are exactly pin 1 (expected 1 to be 2, expected +0 to be 1, and the write must be refused twice); pin 2 and the three pin-3 tests stayed green. Restore was git checkout HEAD -- PATH: blob back to d57cbd3078 (the HEAD blob), git diff HEAD 0 bytes, git status --porcelain empty. No dist rebuild per leg was needed: the pin imports ./engine.js / ./plugin.js from src by relative path.

Tests

Head of record: b73f58e396 (after merging origin/main at 251a7dd4b4).

  • Pins: pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/seed-readonly-value-shape.test.ts gives Tests 8 passed (8) at b73f58e396. With A seed row's explicit created_at is overwritten with the boot instant on INSERT (seed context sets no preserveAudit), yet written on the upsert UPDATE of a later boot — seeds cannot backdate creation time consistently #21646's pin file beside it earlier: Tests 14 passed (14).
  • objectql: vitest run --project local --maxWorkers=2 gives Test Files 372 passed (372) / Tests 7455 passed (7455) and --project repo gives Tests 5 passed (5), both at e6b5281680. pnpm --filter @objectstack/objectql run typecheck exits 0, with check:test-typecheck: OK … 40 file(s) / 234 error(s) / 65 pinned signature(s) held (ledger unchanged). tsc -p tsconfig.test.json --listFilesOnly lists the new pin file. The only change after e6b5281680 is the pin file's row-key rename (rerun green above).
  • H5 consumer suites (pnpm --filter PKG run test, against objectql's rebuilt dist/, at 45804afc28): every one green, with the counts in the H5 section. The commits after it are behaviour-identical for these suites: the engine-internal entry refactor, the changeset, a merge of main with no objectql overlap, and the pin rename.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths derives 68 commands at b73f58e396, from 7 paths against merge base 251a7dd4b. All 68 ran, each exit code captured before any pipe: 68 × exit 0. --ran reports 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. Two readings came from the first union at e6b5281680:
    • check:error-code-casing was red. It read the pins' row key, a field named code, as a lowercase error code. The key is renamed to ref, and the gate is green from b73f58e396.
    • check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET). After a full turbo run build (72/72) it exits 0.
  • Artifact-roster block (53 families outside the derived total, all run at b73f58e396): 50 exit 0. check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths report NOT WIRED with no PR context (exit 2, not a verdict). check-partof-closing-keyword was then run with this body as PR_BODY: exit 0, "no Part-of/closing-keyword contradiction". The other two need the PR number, and their results go in the os-dev-report on the card.
  • Changeset gates: check-changeset-no-major, check-adr-0087-registration (1 declared-breaking changeset, carrying its disposition) and check-empty-changeset all exit 0.
  • NOT MEASURED (CI-only, no local invocation): shard attestation and test-completeness, the Test Core / Temporal Conformance / Dogfood / Dogfood Verify / Build Core jobs, the workspace and consumer type-check lanes, and the 11 declared wide-population families. Repository-wide pnpm lint is CI-owned and was not run.

Acceptance notes

  • owner_id keeps the full skip. It is system but not readonly, so the split does not reach it (no strip exemption is involved). It is caller-writable and its value shape is still never judged. This is read-only inference, not measured through a door.
  • The ADR-0104 dormancy test still excludes readonly columns (isScannableValueShapeField). Widening it would make every object non-dormant through its injected readonly lookups. So an object whose only covered fields are readonly stays warn-first for them: a malformed readonly reference is admitted, logged and reported to onAdmittedValueShapeViolation, never stored silently. The os migrate value-shapes scan population is unchanged.
  • AppPlugin's fallback seeders log a refused row at warn and then report "Data seeding complete", while SeedLoaderService logs the same loss at error. This is pre-existing and not caused here. carrier: none.
  • Comments elsewhere still say "validateRecord skips readonly fields" (plugin-audit sources and tests, and two ADR-0087 semantic entries in packages/spec/src/migrations/). What they rely on, that a readonly option set is not enforced, stays true by the boundary above. The stated reason is now imprecise. Not edited here. carrier: none.
  • The dry run never applies normalizeMultiValueFields, for any field, so a scalar on a multi-value field previews as invalid while the write wraps and accepts it. This is pre-existing, and readonly fields now behave the same as the rest. carrier: none.

Generated by Claude Code

claude added 7 commits October 4, 2026 04:49
…d for its shape

The static readonly strip exempts a system write (seed replay, migration,
hook stamps), and the record validator skipped every readonly field on the
premise that the strip had removed anything a caller sent. So under
isSystem a readonly value reached the driver unjudged: a seed's 'yesterday'
on a readonly datetime, or an unresolved cel envelope, was stored verbatim
while the same value on a non-readonly field was refused.

The strip keeps its system exemption. validateRecord gains a
readonlyValues scope ('skip' | 'include' | 'only'): the engine judges each
readonly value's SHAPE wherever the payload is final - in the same call on
insert and in the dry run (both after their strips), and in a second pass
after the strip on both update paths. A readonly value reaches the type's
shape arms only, with the non-readonly sentence: never required, and never
an author-declared constraint (option membership, bounds, valueDomain,
formats), which keeps the open-vocabulary ruling on sys_activity.type.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
… all four write seams

Triage's three pins on the real SeedLoaderService and the engine's own
seed context: 'yesterday' on a readonly datetime is refused and counted as
a seed error with the non-readonly sentence (insert, replay update, by-id
and predicate update, dry run; VALIDATION_FAILED / 400 at the boundary); a
valid ISO value - authored, evaluated from cel, or on created_at - is kept;
the non-readonly path, the non-system strip and the readonly constraint
arms (option membership, bounds) are unchanged.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
engine-insert-static-readonly-strip: the isSystem 'strict adds no second
policy' case used the placeholder 'x' in a readonly datetime. Its subject is
strictReadonlyWrites, not value shape, so it now writes a valid instant like
its isSystem sibling (a respelling, the case still pins what it pinned).

record-validator.number-value: the normalizer's exclusion list pinned 'not a
readonly field'. The number arm now judges a readonly value, so its numeric
string is written as its number - #20309's own invariant. The case moves the
readonly field to the rewritten side and keeps every other exclusion.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…ope is engine-internal

The readonly scope is a fact only the engine's write path knows (whether
its payload stands before or after the readonly strip), so it moves off
the public ValidateRecordOptions onto a module-internal
validateRecordInScope. validateRecord keeps its signature and behaviour
byte for byte, and every engine seam now names its scope explicitly:
'skip' before the update strip, 'only' after it, 'include' on insert and
in the dry run.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…em write's readonly value is judged for its shape

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…med 'code'

check:error-code-casing reads a lowercase literal under a 'code' key as an
ADR-0112 error code. The pins' external-id field was named 'code', so every
row key ('bad', 'iso', ...) read as one. The field is arbitrary; renamed.

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

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/validation/rule-validator.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/validation/rule-validator.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 71 pages)
  • 3 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 — 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 38bef8cf95f1d0e3d2dc268eeef5e6b30718eed1 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 38bef8cf95f1d0e3d2dc268eeef5e6b30718eed1

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21695 at head b73f58e396

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T06:36Z. The os-dev report is on #21663. Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.
    • The first lines are Fixes #21663 and Clause-②: no (narrowing).
    • The closing-keyword scan finds #21663 only. #21646 appears in the body with no verb next to it.
  • Scope: 7 files, +672/-42, all in packages/objectql: record-validator.ts, engine.ts, docs in rule-validator.ts, one new pin file, two re-judged fixtures, and the changeset. NOT governed. No packages/spec file is touched. A local git merge-tree against origin/main is clean.
  • H1's file location is falsified, and the fix moved to the producer — accepted. This is the order's file-surface clause.
    • The strip lives in rule-validator.ts, but the readonly skip of the shape check was validateRecord's if (def.system || def.readonly) continue in record-validator.ts.
    • On both update paths the engine ran that check before the strip.
  • The diff, read:
    • isInReadonlyScope sends a readonly field to scope !== 'skip'. A caller-writable field keeps the old walk (SKIP_FIELDS, system and readonly excluded).
    • system-but-not-readonly (owner_id) is reached in no scope.
    • The engine passes 'include' on insert and on the dry run, both after their strips.
    • Both update paths keep the first call at 'skip', ahead of the strip, and add an 'only' pass right after assertNoStrictDrops(), where the payload is final. Judging after the strip is what keeps a non-system whole-record write-back that echoes a legacy value a save, not a refusal. Pin 3 holds this.
    • The published validateRecord delegates with 'skip', so its behaviour is unchanged. validateRecordInScope is module-internal.
  • The boundary is shape, never a constraint — accepted as within the ruling. The ruling names "the value-shape check".
  • The numeric-string reading and the multi-value wrap on readonly fields — accepted, not a "silent coercion". The ruling's ⛔ is about a malformed value: nothing malformed is rewritten, and 'yesterday' and a raw cel envelope are refused. A well-formed '5' gets the door's declared reading, which every non-readonly number field already gets, so judged equals stored (record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309). The changeset says so.
  • Clause-②: no (narrowing) — accepted. A system write that stored a malformed readonly value is now refused, and nothing widens. There is no path leg and no yes, so no contract review is owed.
  • Changeset, checked sentence by sentence:
    • @objectstack/objectql minor, the BREAKING banner, and adr-0087: not-required (no-migration-prescription) with its facts.
    • "The static readonly strip still exempts a system write … still drops a non-system caller's readonly value" matches isInReadonlyScope and pin 3.
    • The refusal envelope (VALIDATION_FAILED, 400, the non-readonly sentence) matches pin 1's four-seam case.
    • "On insert, on the dry run, and on both update paths" matches the four engine seams.
    • The refused and not-checked lists match the shapeOnly placements.
    • "validateRecord, as exported, is unchanged" matches the delegation.
  • Pins and evidence:
    • Pins run on a real ObjectKernel, ObjectQLPlugin and the real SeedLoaderService, and each refusal asserts [code, status] = ['VALIDATION_FAILED', 400] (ADR-0112).
    • Reverse verification at 196b217829: the one predicate was reverted, and exactly pin 1's 4 cases went red while pins 2 and 3 stayed green. The restore was proved by blob equality, a 0-byte git diff HEAD and an empty status.
    • objectql: 7455 of 7455 tests pass. typecheck exits 0, with the test-typecheck ledger held.
    • H5 consumer suites pass against the rebuilt dist: plugin-audit, plugin-security, plugin-auth, plugin-approvals, service-automation, metadata-protocol, runtime, verify and plugin-pinyin-search.
    • Two objectql fixtures were re-judged, not relaxed. An isSystem 'x' in a readonly datetime was respelled to a valid instant. The normalizer pin moved readonly to the rewritten side, by its own judged-equals-stored invariant.
  • H4 — the foreseen follow-up, measured.
    • AppPlugin's two fallback inserts and @objectstack/verify's seed() now refuse a raw cel envelope on a readonly field.
    • No example or test newly fails. The 10 app-showcase rows that seed created_at with cel also seed a non-readonly due_date with cel, so the fallback path already refused them.
    • No cross-lane card is needed for this change.
  • Gates:
    • dispatch-gates --ran: 68 derived, 68 run, every one exit 0.
    • The artifact-roster block (53 families) is green, including check:error-status-conformance and the 3 PR-context guards re-run with this PR's context.
    • One real red was caught and fixed in-branch: check:error-code-casing read a pin row-key named code, which was renamed to ref.
  • Deviations — accepted:
    • Eight pushes under the single git push budget line, none forced.
    • The consumer suites ran at 45804afc28, and the later commits do not change their behaviour.
    • The attribution follows AGENTS.md.
    • The report comment was sent from the shared checkout's relay modules, which were hash-checked byte-identical to the branch's.
  • CI: read by the seat at landing. The seat lands only once every check is green or an expected skip.

Out-of-scope findings — Acceptance notes, not filed: none has a measured public-door reach.

  • owner_id (system, not readonly) still skips the shape check on every writer.
  • AppPlugin's fallback seeders log a refused row at warn and then report "Data seeding complete". This is pre-existing.
  • plugin-audit comments and two ADR-0087 entries give "validateRecord skips readonly fields" as their reason. What they rely on still holds under the shape-only boundary, but the stated reason is now imprecise.
  • The dry run never applies normalizeMultiValueFields. This is pre-existing.

Generated by Claude Code

…dateRecord again

ADR-0020 anchors packages/objectql/src/engine.ts#validateRecord and quotes
the update path's call, validateRecord(schema, hookContext.input.data,
'update'), as the one that sees only the PATCH payload. Spelling that call
as validateRecordInScope(..., 'skip', ...) left the anchor unresolved
(check:adr-symbol-anchors). The public validateRecord IS that scope, so
the two pre-strip calls use it again: no behaviour change, the anchor
resolves on the call it names, and the ADR text stays true as written.

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

Copy link
Copy Markdown
Contributor Author

ACCEPT addendum — PR #21695, patch round 1, head 5c58fabb6e

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T06:59Z. This carries ACCEPT 5977368329 from b73f58e396 to this head. The dev's addendum is 5977501116 on #21663.

  • The red it fixes: Lint & Repo Gates at b73f58e396 (job 111379104429), step "ADR symbol anchors resolve". docs/adr/0020-state-machine-converge-and-enforce.md:52 anchors packages/objectql/src/engine.ts#validateRecord, and this PR had respelled the update path's pre-strip call to validateRecordInScope(…, 'skip', …). The seat reproduced it with node scripts/check-adr-symbol-anchors.mjs.
  • The delta, read: engine.ts only, +15/-13, one single-parent commit on b73f58e396. Both update paths' pre-strip calls are validateRecord(updateSchema, data, 'update', opts) again. The published function delegates to validateRecordInScope(…, 'skip', opts), so the behaviour is identical. The post-strip 'only' passes and the insert and dry-run 'include' calls are unchanged.
  • Route — accepted over the seat's recommendation. The seat had suggested re-anchoring ADR-0020. docs/adr/** is a governed surface (GOVERNED_SURFACES), so that would have made this PR Tier H. Restoring the call's original spelling resolves the anchor, keeps the ADR's quoted call literally true, and leaves docs/adr untouched.
  • Gates at 5c58fabb6e:
    • the four symbol-anchor sweeps exit 0, and check:adr-symbol-anchors resolves 2167 anchors across 140 records;
    • dispatch-gates over the ADR path plus engine.ts: 61 derived, 61 run, every one exit 0;
    • objectql typecheck exits 0, with its ledger held;
    • the four affected suites pass 401 of 401.
  • The gate-derivation gap that let this through is filed as [finding] dispatch-gates derives the symbol-anchor sweeps only from their corpus paths, so a change that removes an anchored symbol from a TARGET file passes every local gate and reds CI #21697.
  • CI: read by the seat at landing on this head.

Generated by Claude Code

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

2 participants