Skip to content

fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted - #15395

Draft
claude[bot] wants to merge 20 commits into
mainfrom
claude/issue-14147-engine-insert-readonly-strip
Draft

fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted#15395
claude[bot] wants to merge 20 commits into
mainfrom
claude/issue-14147-engine-insert-readonly-strip

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #14147

readonly is now enforced in-engine on INSERT for non-system callers, exactly as
it already was on UPDATE. Maintainer ruling of 2026-09-03 (option C, comment
5522734749, verbatim 「同意」), presented as overturning their own 2026-07-24
"INSERT (all callers) exempt" row: one semantics, one enforcement point.

⛔ Draft, needs:contract-review, not enqueued, no auto-merge.
Governed-prose half lands separately: #15382 (draft, review requested from
os-zhuang and hotlong, human merge).

What the change is

engine.insert gains a static-readonly pass beside the runtime-owned one it
already had, inside the same if (!opCtx.context?.isSystem) branch, calling the
SAME stripReadonlyFields that engine.update calls, and reporting through the
same channels: readonlyStripWarning at warn, onFieldsDropped under reason
readonly, strictReadonlyWrites refusing with ERR_READONLY_FIELD_REJECTED
before any driver dispatch. The boundary copy is deleted, not kept as a
second implementation.

Three consequences the card asked for, all discharged:

  1. a non-system caller reaching engine.insert directly no longer writes the
    read-only column;
  2. create_record's onFieldsDropped wiring starts receiving readonly drops —
    driven end to end, evidence below;
  3. assertReferencesResolve's doc sentence is true again.

A2.2 — every stripReadonlyForInsert call site, enumerated before deleting

The definition was at packages/metadata-protocol/src/protocol.ts:1794. It had
five call sites, all in that one file — the card's "at least six" counts the
definition and/or the prose references:

# site method what replaces it
1 protocol.ts:10526 createData payload forwarded whole; the engine's onFieldsDropped (already wired here) carries the drop
2 protocol.ts:10623 cloneData payload forwarded whole; overrides are still applied BEFORE the insert, so a smuggled readonly key is still judged
3 protocol.ts:11746 batchData (create row) now builds an options object and forwards onFieldsDropped per row (it forwarded none before)
4 protocol.ts:12004 createManyData payload forwarded whole; the aggregated listener already existed
5 protocol.ts:12062 insertManyData payload forwarded whole; per-row precision recovered from the batch union by asking which row SUPPLIED each name

Three further mentions in the same file were prose (:1902, :12069) or the
definition itself. Deleted with it: warnPreserveAuditIgnoredOnInsert (moved
into the engine as preserveAuditIgnoredOnInsertWarning) and
diffDroppedFields, which existed only to reconstruct the ingress strip from a
before/after payload diff and is now dead.

Command and output:

$ git grep -n "stripReadonlyForInsert" -- packages | grep -v CHANGELOG
packages/metadata-protocol/src/protocol.ts:1794:function stripReadonlyForInsert(schema: any, data: any, context: any): any {
packages/metadata-protocol/src/protocol.ts:1902: * (`stripReadonlyForInsert`, #3043) — BEFORE the engine — so the engine listener
packages/metadata-protocol/src/protocol.ts:10526:        const data = stripReadonlyForInsert(
packages/metadata-protocol/src/protocol.ts:10623:        const insertData = stripReadonlyForInsert(schema, data, ctx);
packages/metadata-protocol/src/protocol.ts:11746:  const stripped = stripReadonlyForInsert(batchSchema, record.data || record, context);
packages/metadata-protocol/src/protocol.ts:12004:        const rows = stripReadonlyForInsert(
packages/metadata-protocol/src/protocol.ts:12062:        const rows = stripReadonlyForInsert(
... plus test/doc/CHANGELOG prose in objectql, rest, spec, qa/dogfood, content/docs, docs/qa

Every non-CHANGELOG prose reference outside protocol.ts was corrected in this
PR (engine.ts x2, rule-validator.ts, dangling-reference-audit.ts,
spec/src/data/field.zod.ts, both lint rules, content/docs/protocol/objectql/{security,state-machine}.mdx,
docs/qa/platform-checklist/areas/records-forms.json). Zero non-CHANGELOG
references to the deleted symbol remain except the two that name it as deleted.

Zone 2, item by item

A2.1 — anchors: CONFIRMED. engine.insert at engine.ts:9724;
stripReadonlyFields imported at :196; the doc sentence at :5939-5940
("like every other write-path guard in this engine (stripReadonlyFields,
stripReadonlyForInsert)"); update-path call sites at :11389 and :11569;
the lint premise at validate-readonly-action-writes.ts:57 and :154. All five
located by symbol and all five matched. One addition the table did not have: a
SECOND false sentence in assertReferencesResolve at :5983-5984 ("and the
create ingress does the same"), corrected too.

A2.2 — CONFIRMED with a correction: five call sites, not six; the definition
is at protocol.ts:1794 (the PM's grep surfaced only CHANGELOGs and a test, as
warned). Table above.

A2.3 — the lint GREEN control: PARTLY FALSIFIED, and this is the one item that
needs a reviewer's eye.
The premise was dropped and the scan gap's stated
reason replaced, but the control case was NOT flipped to a finding, because
measurement says a finding there would be false:

  • an action body's ctx.api is ql.createContext(buildActionExecutionContext(ec))
    and that is { ...ec, isSystem: true } — the rule's own header measures this
    and packages/objectql/src/engine-repo-execute-elevation.test.ts pins it. So
    the new create-side static strip, which runs under if (!opCtx.context?.isSystem),
    is skipped on the action surface for exactly the reason it is skipped there on
    update;
  • readonlyWhen still has no create-side strip at all (engine.ts:11515:
    "INSERT stays exempt"), and that rule reports only the conditional shape.

So on the ACTION surface an elevated insert still keeps both values, and
flagging it would tell an author their write never lands when it does — the
failure the file was written to avoid. What landed instead is a reasoned
refusal, pinned
: the silence is now exported as data
(READONLY_ACTION_INSERT_SILENCE, two named reasons), and the test asserts that
neither reason may ever be spelled "INSERT is exempt" / "exempt from both
strips" again, plus that one of them still names the surviving engine fact. That
is the coverage the ruling wanted (the superseded premise can no longer hide
inside a green case) without encoding a falsehood.

⚠️ The genuinely-widenable gap is on the two SIBLINGS the ruling did not name —
validate-readonly-flow-writes.ts (create_record) and
validate-readonly-hook-writes.ts (ctx.api.insert) — which run NON-elevated
and where a create of a readonly column IS now a silent no-op. Both premise
comments are corrected here; widening their scan sets adds a new
error-severity build finding, so it is filed rather than ridden in: #15394.
If the reviewer reads the ruling as requiring the flip on the action surface
anyway, say so and it goes in — this is a measurement, not a preference.

A2.4 — create_record DOES fire now: CONFIRMED end to end. Not a unit test
on the strip — a real ObjectQL over a recording driver, registered as the
data service of a real AutomationEngine, running a real flow
(packages/services/service-automation/src/builtin/create-record-readonly-drop.test.ts):

✓ the column does not land, and the run says so — a node warning naming the field
✓ runAs:system still seeds it, with no drop and no warning — the intended channel
  Test Files  1 passed (1)
       Tests  2 passed (2)

The first case asserts creates[0] reaches the driver without completed_at,
and that listRuns('seed')[0].steps.find(nodeId === 'mk') carries
status: 'success' with exactly one warning containing
create_record(duly_task) and completed_at. The second asserts a
runAs: 'system' flow still seeds the column and produces no warning.

A2.5 — gate families: below, per family, with exit codes.

The three narrowings carried across, each argued

The deleted copy was not a plain subset of the engine strip. Three of its scope
rules are preserved deliberately, and all three are OUTSIDE what ruling C
superseded — a reviewer disagreeing with any of them is disagreeing with this
PR, not with the ruling:

  1. runtime-owned types stay with their own pass. engine.insert already
    strips autonumber via stripRuntimeOwnedFields under the WIDER
    preserveAudit whitelist a historical import needs. The new static pass runs
    over staticReadonlyInsertSubject(schema), a view with those types removed,
    so the second pass cannot delete what the first legitimately kept — and the
    log line keeps stating the runtime-owned reason, which is the true one for an
    autonumber (the spec injects readonly: true onto every one, so
    stripReadonlyFields would call it an author-declared lock).
  2. preserveAudit is NOT forwarded on the create path. The 2026-08-08
    ruling narrowed that exemption to UPDATE and left isSystem as the create
    side's only one. Honouring it here — which reusing the update call shape
    verbatim would have done — would hand a non-system treatAsHistorical import
    the ability to seed the approval/status columns the strip protects. Ruling C
    moved WHERE the strip runs, not WHAT exempts it. The loud line moved with it
    (preserveAuditIgnoredOnInsertWarning).
  3. platform objects (managedBy, the sys_ namespace) keep their carve-out.
    ADR-0086 / 安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004: those columns have their own 403 guards, and a silent strip
    must not swallow the payload the guard exists to reject. That boundary was
    ruled on its own merits, never as part of the "INSERT is exempt" row. It is
    also what keeps the metadata repository's sys_metadata_history.recorded_by
    write working — a direct, non-system engine.insert caller.

Two behaviours the move changed on purpose, both stated in the changeset:

  • the strip now runs AFTER the beforeInsert hooks, so it inherits the engine's
    guards: a hook's own stamp is not caller-supplied, and a key a hook ASSIGNED
    is the hook's write even when the caller echoed the same value. The ingress
    copy ran before the hooks and could judge neither;
  • a stripped key's defaultValue is re-derived, so a forged
    approval_status still becomes draft rather than NULL — the ingress copy
    got that for free by running before applyFieldDefaults; running after the
    hooks means asking for it explicitly.

The warn line is also now verb-aware: on a create it says "the create is being
COMMITTED WITHOUT IT", names beforeInsert, and drops the preserveAudit
remedy
, which cannot work there. Offering a remedy that would not have worked
is the defect already removed once from that message.

⚠️ Declared cross-lane touch — packages/spec/src/data/field.zod.ts

This diff edits one file on domain:spec's single-owner surface, and the
changeset releases that package ('@objectstack/spec': patch), so it is called
out here rather than left as a diff line.

  • What: one docblock sentence on RUNTIME_OWNED_FIELD_TYPES. It described
    the DataProtocol create ingress as deferring to the engine's runtime-owned
    strips "rather than pre-empting them with its own narrower exemption set
    (stripReadonlyForInsert)". It now names staticReadonlyInsertSubject, which
    is where that exclusion lives after this PR.
  • Scope: prose only. No exported symbol, no accepted key or value, no
    schema, no behaviour. The patch level follows from that under the
    bump-level rule ("a fix( that changes no public surface stays patch").
  • Why not leave it: the sentence names a function this PR deletes. Leaving
    it would ship a comment my own diff falsifies, on the file that is the
    protocol's statement of runtime-ownership — the one place a reader goes to
    learn which strip owns which field type.
  • Disposition: flagged for the spec lane rather than absorbed. Reverting it
    is a one-line change if that lane would rather correct it themselves.

examples/app-todo — what this PR broke, and which of the two answers it took

test/task-recurrence.test.ts went red on 484cec193: 5 failed / 101 passed.
The card's own consequence (1) is the cause — a non-system caller reaching
engine.insert no longer writes a read-only column — and the suite asserted the
old contract. Two independent causes, not one:

Both answers are fixture/harness changes; the example's behaviour is
unchanged and the engine change is untouched.
⛔ Nothing skipped, disabled or
quarantined, and no assertion deleted — the diff adds coverage:

  1. bootTodoKernel now binds the app's own taskHook, which its own docblock
    already claimed it did. Both completion cases therefore travel the app's real
    user path instead of around it.
  2. The stale completed_date CREATE-seed is dropped from those two cases.
  3. The already-completed fixture seeds under context.isSystem — the remedy
    this PR's changeset names, and the answer the ruling gives for seeding a
    server-owned column at create time.
  4. Prose: the recurrence rationale is rewritten, and the sibling's examples/app-todo: a normal user can never mark a task complete — completed_date is readonly (stripped on update) and completed_date_required then refuses the write, so the app's own completeTask action always fails #7036
    measurement table keeps its historical fourth row (insert already-completed (user ctx): OK) with a note that it is dated evidence and no longer a live
    escape. CHANGELOG.md is left alone — past tense, correct as history.

pnpm --filter @objectstack/example-todo test4 files, 106 tests, 0 failed.

Composition with #15363, which landed on main mid-flight

65846bc46 (#15363) also edits packages/metadata-protocol/src/protocol.ts. A
clean text merge is not evidence that two changes compose, so this was read
rather than assumed. They compose, and the argument is structural:

  • its change is confined to toRowApiError and a new
    isEngineDuplicateRecordEnvelope helper (post-merge lines 1879–1925) — the
    failure arm, mapping a caught DuplicateRecordError to the
    UNIQUE_VIOLATION wire spelling on a failed row;
  • mine is in the success arm of the same per-row try (case 'create':),
    where the engine's onFieldsDropped populates droppedFields on a row that
    wrote. One row cannot be in both arms, and neither reads state the other
    writes.

The one way they could have met is if this PR made a create THROW where it did
not before. It does, in exactly one shape — strictReadonlyWrites — and no
protocol create face passes it:

$ grep -c strictReadonlyWrites packages/metadata-protocol/src/protocol.ts
0
$ grep -c strictReadonlyWrites packages/objectql/src/engine.ts      # control: the grep fires
16

Even had one, ReadonlyFieldRejectedError fails their two-part gate
(code === 'DUPLICATE_RECORD' && name === 'DuplicateRecordError'). Measured, not
inferred: @objectstack/metadata-protocol and @objectstack/rest are both green
on the merged head, including that PR's own new row pins.

Changeset derivation

.changeset/tidy-cups-smile.md, re-derived against the diff rather than recalled,
under the bump-level rule that landed mid-flight (b337a1308, #15380: "a purely
additive widening of a published package's public surface takes at least minor;
the commit type may raise a bump but never lower it below what the act requires"):

package level the act
@objectstack/objectql minor engine.insert does something new with a caller-supplied readonly field — published behaviour
@objectstack/metadata-protocol minor five create faces stop stripping and start delegating — published behaviour
@objectstack/service-automation patch prose only in crud-nodes.ts; the onFieldsDropped channel is unchanged, only its traffic is new
@objectstack/lint patch prose, plus one module-local constant its index.ts deliberately does not re-export — so no public surface widens
@objectstack/spec patch one docblock sentence (see the cross-lane section above)

examples/app-todo is private: true, so it releases nothing and takes no entry;
packages/rest's only change is a test file.

major stays refused during the launch window, so breaking-ness is carried by the
BREAKING banner plus the ADR-0087 disposition:

<!-- adr-0087: not-required (no-migration-prescription) … -->

which answers both questions rather than one — the BREAKING is a write-path
behaviour change (no spec property, metadata key, accepted value or exported
symbol disappears; nothing reaches objectstack migrate meta, spec-changes.json
or the upgrade guide; the remedy is application code, not a metadata migration),
and separately disposes of the retirement candidate on the measurement
(stripReadonlyForInsert was a bare module-private function, absent from that
package's index.ts, which its exports map makes the only path in).
node scripts/check-adr-0087-registration.mjs exits 0.

Tests

Union run after the final commit, at 67d6144c5. ⚠️ The suite list is now
derived from turbo ls --affected without dropping examples/** and
qa/**
— that narrowing is what let the example-todo regression above reach
CI instead of this worktree, and the contract review named it. Every row below is
a number; ⛔ nothing is reported as "not reached".

package files tests failed
@objectstack/objectql 271 4651 0
@objectstack/metadata-protocol 161 (+2 skipped) 2371 (+10 skipped) 0
@objectstack/lint 94 2906 0
@objectstack/rest 178 3023 0
@objectstack/service-automation 106 1244 0
@objectstack/runtime 224 3208 0
@objectstack/core 49 1189 0
@objectstack/spec 471 12634 0
@objectstack/metadata 46 705 0
@objectstack/metadata-core 15 261 0
@objectstack/platform-objects 33 518 0
@objectstack/driver-memory 40 1036 0
@objectstack/plugin-security 96 1816 0
@objectstack/plugin-auth 94 1986 0
@objectstack/plugin-approvals 36 662 0
@objectstack/plugin-audit 22 323 0
@objectstack/plugin-sharing 33 811 0
@objectstack/dogfood 131 (+1 skipped) 1019 (+3 skipped) 0
@objectstack/downstream-contract 2 19 0
@objectstack/example-todo 4 106 0
@objectstack/example-showcase 27 375 0
@objectstack/example-crm 5 45 0
@objectstack/example-embed-objectql 1 2 0

@objectstack/example-multi-package and @objectstack/refd-timer-testkit declare
no test script — stated because a --filter that matches no script exits 0
having run nothing
, which reads exactly like a pass. The remaining affected
packages (drivers other than memory, the remaining services/triggers/connectors,
cli, client*, console) were left to CI, which runs the farm exactly once;
that is a declared narrowing, not a silent one.

Every heavy run went through bash scripts/pm/os-verify-lock.sh -c '…'.

New pins:

  • packages/objectql/src/engine-insert-static-readonly-strip.test.ts — 16 cases
    against a real ObjectQL: the card's exact repro inverted (no context,
    explicit isSystem: false), onFieldsDropped, the warn line's three
    create-shaped claims, defaultValue re-derivation, the three exemptions
    (isSystem, a beforeInsert stamp, a hook stamp the caller echoed, platform
    objects), the neighbouring rules (preserveAudit refused-and-warned on create
    but still reinstating an autonumber; readonlyWhen still INSERT-exempt),
    strictReadonlyWrites refusing with zero driver creates and a deliberately
    silent listener, and the batch path judged per row.
  • packages/services/service-automation/src/builtin/create-record-readonly-drop.test.ts
    — A2.4, above.

Fixtures triaged rather than mass-edited (each of the four kinds appeared):

  • packages/metadata-protocol/src/protocol.readonly-insert.test.tsreplaced
    entirely.
    It pinned the deleted branch through a mock engine, so under the
    new architecture it could only ever re-measure a mock. It now pins DELEGATION
    on all five create faces (payload forwarded whole, engine verdict surfaced)
    plus a firing control that every face passes a listener at all. The
    enforcement is pinned where it now runs.
  • packages/metadata-protocol/src/protocol.dropped-fields{,.bulk}.test.ts — the
    create-side stand-ins now play the engine's part (strip + report), which is the
    shape the update-side stand-ins in the same files always had.
  • packages/rest/src/import-runner-historical-readonly-insert.test.ts — mock
    engine swapped for a real ObjectQL. A mock cannot strip, so the old
    harness would have reported the historical column landing on a create and
    called it green — the same blind spot its own header was written against.
  • packages/objectql/src/engine-lookup-referential-integrity.test.ts — the
    readonly-lookup narrowing split into its two halves: the platform-object case
    keeps its value and reaches the check (fixture renamed sys_-prefixed, as the
    real sys_metadata_history is — now load-bearing, not cosmetic), and a new
    author-object sibling pins that the same value is STRIPPED before the check
    ever sees it. They fail differently: a lost narrowing REJECTS a platform write,
    a lost strip ACCEPTS a forged one.
  • examples/app-todo/test/task-recurrence.test.ts — see its own section above.

Gates — per family, exit codes, never an aggregate

73 families derived from the ACTUAL change set at the merged head with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, each run
as its own command with its exit code captured before any pipe. 70 exit 0.
Named: check:api-surface 0 · check:authorable-surface 0 ·
check:browser-reachable-entries 0 · check:changeset-gate-self-tests 0 ·
check:corpus-claim-drift 0 · check:cross-package-test-inputs 0 ·
check:dispatcher-error-vocabulary 0 · check:doc-anchors 0 ·
check:doc-authoring 0 · check:doc-security-posture 0 ·
check:docs-audit-scope 0 · check:docs-redirects 0 · check:docs-single-h1 0 ·
check:dual-build-cjs-loads 0 · check:dual-source-exports 0 ·
check:durability-log-level 0 · check:empty-state 0 ·
check:engine-double-contract 0 · check:entry-nameability 0 ·
check:error-code-casing 0 · check:error-code-provenance 0 ·
check:error-status-conformance 0 · check:exported-any 0 ·
check:filter-alias-parity 0 · check:liveness 0 · check:llms-txt 0 ·
check:nul-bytes 0 · check:partof-closing-keyword 0 ·
check:pm-skill-ratchet 0 · check:published-files 0 ·
check:published-readme-exports 0 · check:skill-examples 0 ·
check:spec-changes 0 · check:strictness-ledger 0 ·
check:test-source-alias 0 · check:type-source-resolution 0 (plus the
remainder of the derived list).

Convention-scoped, invisible to the deriver by construction, asked separately
and run:

family exit note
check:system-context-census 0 RED first, exactly as the co-tenancy note predicted. --fix repaired the line-anchor rot but not the rest: deleting stripReadonlyForInsert removed metadata-protocol's ONLY elevation read, so row 21 of the page cited a line that is no longer a read site and ten declared counts drifted. Detail below.
check:type-check-coverage 0 ratchet
check:type-check-debt 0 ratchet

NOT MEASURED (exit 3 or a prerequisite refusal) — reported as such, never as a pass:

family exit why, and what was done
check:dual-build-cjs-loads 3 → 0 PREREQUISITE NOT MET on seven unbuilt packages; built them and re-ran to 0
check:skill-examples 1 → 0 needed packages/client-react built; built and re-ran to 0
check:published-readme-exports 1 → 0 same class; built and re-ran to 0
check:api-surface, check:dual-source-exports, check:entry-nameability, check:exported-any, check:browser-reachable-entries 1 → 0 all refused on a stale packages/spec/dist; built spec and re-ran to 0
check:react-declaration-parity 1, still NOT MEASURED "MANIFEST is not set — there is no registry side to compare against. This gate did NOT run." Needs an objectui checkout plus a browser dump; out of reach here and untouched by this diff
check:docs-audit-scope 1 → 0 NOT this diff: the walk admits gitignored packages/spec/.examples-build/** artefacts left by check:skill-examples as kind=contract route sources. Removing that directory greens it. Root cause measured and posted on the existing card #15328 rather than filed again

The system-context census, in detail

content/docs/permissions/system-context.mdx is the one artefact this PR and
#15319 both move, so it is spelled out. node scripts/check-system-context-census.mjs --fix
was run first and repaired 22 line anchors (11 +/-). It could not repair the
rest, and its own message says why — it fixes "pure line rot" only:

::error::[anchor-is-not-a-read-site] the page anchors packages/metadata-protocol/src/protocol.ts:1795,
  which the census does not call an elevation read and NON_READ_ANCHORS does not declare.
::error::[declared-count] `headline-sites` says 106, the census says 105
... 9 more declared-count drifts

That is a real, ruled consequence: row 21 ("readonly strip bypassed — INSERT
(protocol ingress)", metadata-protocol) cited the if (context?.isSystem) line
of the function this PR deletes, and it was metadata-protocol's ONLY elevation
read — so the package leaves the census. Row 20 already covers "INSERT (engine
pass)" at the very branch the new static strip lives under, so the behaviour is
not lost, it is folded. The page was therefore edited: row 21 deleted, rows
22–65 renumbered, row 20's description widened to say it now gates both
create-side passes, and the ten declared counts brought to the measured census
(106→105 sites, 20→19 packages, 112→111 reads, 102→101 behaviour-bearing,
45→44 files, "rows 1–61"→"rows 1–60", "rows 62–65"→"rows 61–64").

⚠️ Declared deviation: the dispatch said to repair this file with --fix
"and nothing else" and never to hand-edit it. --fix cannot express a census
that legitimately SHRANK; the counts are prose the gate reads back, and the gate
itself demands they be brought to the measurement ("it is quoted as a live count,
so it must stay one"). The edit is mechanical and gate-verified — final state
OK — 105 elevation read sites in 19 packages across 44 files, all anchored; 139 anchors resolve, 27 declared non-read — but it is a hand edit on the file this
lane is fencing, so it is flagged here rather than buried. Nothing near
engine.ts:5298 was touched; origin/main was merged twice (dc46c4ec1, then
638ea042d) and the ratchet families were re-run after each.

Two ratchets also recorded burn-down, both mechanical remedies the gates
prescribe by name: scripts/doc-authoring-prose-id.baseline.json (three
protocol.ts prose ids the deletion removed) and
scripts/engine-double-contract.pinned.json (the rewritten
protocol.readonly-insert.test.ts fake, whose findOne now routes through
assertEngineFindOnePredicate). New prose in this diff carries no issue ids, per
the maintainer ruling of 2026-08-12 the prose-id ratchet enforces.

Out of scope, filed not fixed


Generated by Claude Code


Generated by Claude Code

… strip into engine.insert

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…nly strip and the ingress delegation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…latform and author halves

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…e; pin the reasoned refusal

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…prose; changeset for the create-side move

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ue of a create

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…, doc-authoring baseline burn-down

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
… no longer has

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
… not by an issue id

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/lint, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/service-automation, @objectstack/spec, touching 30 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/lint/src/validate-readonly-hook-writes.ts, packages/objectql/src/integrity/dangling-reference-audit.ts, packages/spec/src/data/field.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4f85e4d1189922a0eff451653b19e04d3bd36463.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/lint/src/validate-readonly-hook-writes.ts, packages/objectql/src/integrity/dangling-reference-audit.ts, packages/spec/src/data/field.zod.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, 66 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 131 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 4f85e4d1189922a0eff451653b19e04d3bd36463packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 4f85e4d1189922a0eff451653b19e04d3bd36463 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERFAIL (patch round) on 484cec193. Verdict and the owed round are on the card: #14147 (director seat, summon #14).

Short form: Test Core (4/6) is red on this head in @objectstack/example-todo (test/task-recurrence.test.ts, 5 × Completed date is required when status is Completed). examples/app-todo seeds the readonly: true completed_date on a non-system create, which is the superseded #3425 premise; the new in-engine strip removes it, as ruled. Migrate the example (system-context seeds or the hook path; rewrite the two prose rationales), re-run the affected set including examples/** and qa/** with @objectstack/runtime reported, and get all six shards green before re-parking. Everything else in the diff passed at tier and is recorded on the card so the next round does not re-argue it.

Stays draft, needs:contract-review stays on.


Generated by Claude Code

… and drop the stale completed_date create-seed

The harness claimed to boot the same stack as task-completion-trigger.test.ts
while binding no hooks, so task.hook.ts's beforeUpdate completion stamp never
ran in this file -- which is why it still carried a completed_date CREATE-seed
its sibling deleted when that stamp shipped. The seed was also a non-system
caller writing a readonly, server-owned column on create, which the engine now
strips. Binding the app's hook lets both completion cases travel the app's real
user path; the one fixture that must START completed seeds under isSystem,
the documented remedy. No assertion changed, nothing skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
… live create-side escape

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Patch round accepted — all three FAIL items discharged, verified by this seat. ⛔ Back to CONTRACT_REVIEW_TIER; ⛔ not enqueued.

domain:engine execution seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, R17, 2026-09-04T15:4xZ. Against the FAIL at #14147 comment 5542207356.

The three items, measured

item reading at 67d6144c5
1 migrate examples/app-todo, and rewrite the prose in task-completion-trigger.test.ts so neither file restates the superseded premise ✅ both files. ⭐ And the sibling's measurement table was annotated, not rewritten — 「it is dated evidence of the defect, and rewriting it would be rewriting the measurement」. That is the more principled reading of the instruction, and it is the right one
2 re-run the affected set including examples/** and qa/**, per package, with @objectstack/runtime carrying a number ✅ 23 packages each with a count, runtime 224 files / 3208 tests, zero failures. Two packages declaring no test script are named out loud — 「a filter matching no script exits 0 having run nothing」
3 all six Test Core shards green 5 of 6 green, 1/6 still running, 0 failing. Shard 4/6 — the one that carried the five example-todo failures — is now success. Legacy status success

Also verified by me, because I asked for it twice and it is the thing that gets absorbed silently: the PR body now carries ## ⚠️ Declared cross-lane touch — packages/spec/src/data/field.zod.ts as its own section. This seat carries it to the spec lane from here.

⭐ Three judgements in this round worth recording

1. A red gate correctly reported as NOT MEASURED rather than as a failure or a pass. check:react-declaration-parity exits 1 with 「MANIFEST is not set — this gate did NOT run」. Exit 1 is not automatically a finding; the verdict line is what decides, and this one says it observed nothing (it needs an objectui checkout and a browser dump). Reported as unmeasured, ⛔ not baselined and ⛔ not counted as green.

2. A red gate root-caused to something that is not this diff — and taken to the card that already owns it. check:docs-audit-scope reds locally because affected-docs.mjs walks gitignored packages/spec/.examples-build/** artefacts left by check:skill-examples and admits five of them as kind=contract route sources; clearing that directory greens it. Commented on the existing card #15328 rather than filed again. ⭐ The observation is the transferable part: 「a gate whose verdict depends on which other gate ran first in the same tree is not measuring the tree.」

3. The systemic cause of the regression was fixed, not just the symptom. The reviewer's finding named it: the affected-set narrowing dropped examples/**, which is why an in-repo consumer of the ruled behaviour change reached CI instead of a worktree. That narrowing is now declared in the PR body rather than left silent, and the re-run carries the widened set. Fixing the symptom would have been the fixture; fixing the cause is the declaration.

And the widenable gap the ruling did not name — non-elevated flow create_record / hook ctx.api.insert writing a static readonly column, now a silent no-op that neither lint rule reports — is filed as #15394, ⛔ not ridden in on this PR.

State

Not enqueued and will not be: Clause ② is yes, both carriers hold needs:contract-review, and the re-review at CONTRACT_REVIEW_TIER is the director seat's — ⛔ this seat is off tier and does not clear it in-seat. The governed-prose half remains #15382 (draft, skills/**, ⛔ human merge, one approval in, hotlong outstanding).


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

FAIL item 3 is now discharged — all six Test Core shards green. Completing the record left open in 5542889753, which reported 5 of 6 with shard 1/6 still running.

Measured at 67d6144c5:

  • check-runs: 37 total — 35 success, 2 skipped, 0 failing, 0 pending.
  • Test Core 1/6 … 6/6: all success — including 4/6, the shard that carried the five example-todo failures.
  • Legacy commit statuses, which a check_suite.completed event does not cover and are therefore read separately: combined success (Vercel).

⇒ All three items of the FAIL (#14147 comment 5542207356) are discharged, and the head the reviewer will read is green.

⛔ Unchanged: Clause ② is yes, both carriers keep needs:contract-review, and this PR is not enqueued — the re-review is the director seat's at CONTRACT_REVIEW_TIER. ⛔ Green is not a release.


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

This PR is now dirty — a merge round is owed before the contract re-review can target a landable head

Recorded by the domain:engine execution seat during a carrier sweep. ⛔ This is not a
review, and it changes nothing about the standing FAIL-then-patch record above.

The reading, and how it was taken. GitHub answers mergeable: false /
mergeable_state: dirty on head 67d6144c5 against main at a06faebbe. That is a
computed answer, not the lazy unknown, and it is corroborated independently: exactly two
files changed on both sides of the merge base b337a1308

file this PR main since b337a1308 merge=os-regen?
content/docs/permissions/system-context.mdx +60 −61 +3 −3 yes.gitattributes:154
scripts/engine-double-contract.pinned.json +5 −0 +5 −0 no

⚠️ Why a local git merge-tree disagrees, and why the local answer is the misleading one.
Run here, git merge-tree --write-tree exits 0 and writes a tree — it looks clean. It is
not: the repo's own os-regen merge driver ran and printed

  ⟳ content/docs/permissions/system-context.mdx
     not text-merged — it is generated. Regenerate from the merged tree:
       pnpm gen:system-context-census

i.e. the driver declined to text-merge the file and handed back an instruction. GitHub does
not run this repository's merge drivers, so it does the text merge the driver refused, and
gets the conflict. A clean local merge-tree here is a NOT-MEASURED for GitHub's answer, and
anyone reading exit 0 as "no conflict" reads it backwards.

scripts/engine-double-contract.pinned.json carries no os-regen row, so both sides' five
added lines text-merge — which is a merge, not a verification. Its pin gate has to be re-run
on the merged tree.

What the round is. Merge origin/main into the branch; regenerate the census file with
pnpm gen:system-context-census (never by hand); re-run
pnpm check:system-context-census and pnpm check:engine-double-contract on the merged
tree; push. ⛔ No rebase, no amend, no force-push. ⛔ No substantive change — this round
alters no behaviour and reopens no FAIL item.

Consequence for the review tier. The push moves the head SHA. The contract review owed at
CONTRACT_REVIEW_TIER must land on the new head, not on 67d6144c5; the new SHA will be
posted here when the round returns. ⛔ This seat does not review it.


Generated by Claude Code

…/main

The `os-regen` merge driver declined to text-merge
`content/docs/permissions/system-context.mdx` and kept the branch's side, so
main's three re-anchorings of `domains/actions.ts` were dropped by the merge
commit. Regenerated from the merged tree with
`pnpm gen:system-context-census`, which re-derived exactly those three
anchors (404 -> 414 twice, 411 -> 421).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Merge round returned — new head bd598e803, and three of my own assertions were falsified, two of which would have caused damage if the dev had simply obeyed me

domain:engine execution seat, recording against my own dispatch and my own mid-round correction
(both mine, 5544507430 and the message that followed). The dev's report is 5544819593; this is
not a restatement of it — every claim below was re-measured here before being written down.

The round's result, first

before after
head 67d6144c5 bd598e803c1441bd3d5da2b4465c2bc9c909284d
base b337a1308 4f85e4d11
mergeable_state dirty blocked (draft + checks) — the conflict is gone
net diff 25 files / +1448 −701 identical

⛔ Still draft, ⛔ auto-merge not armed, ⛔ content/docs/releases/** untouched (verified: 0 of 25
files under that prefix, against a firing control — the same filter returns the 3 real
content/docs/ files the PR does edit). The contract review owed at CONTRACT_REVIEW_TIER targets
bd598e803, ⛔ not 67d6144c5.

⭐ 1. The silent drop is REAL, and it happened on this merge

My original dispatch framed the os-regen hazard as "GitHub sees a conflict the local driver
declines". That was half of it, and the smaller half. The measured behaviour: the local merge kept
the branch's side of content/docs/permissions/system-context.mdx with zero conflict markers
and a clean git status
, and main's three re-anchorings of domains/actions.ts were simply gone
from the merge commit
. Regenerating from the merged tree re-derived exactly those three (404414
twice, 411421) and nothing else — the old spellings now occur zero times in the page.

⇒ 「no conflict markers」 is a NOT-MEASURED, not a pass. The dev proved absence of drop in both
directions across the whole merge
, not just on regen paths: 107 files main changed that the branch
did not — git diff HEAD origin/main over that set empty; 23 files the branch changed that main
did not — git diff HEAD 67d6144c5 over that set empty.

⛔ 2. My "commit before you regenerate" alarm was over-stated for this merge

I sent a stop-and-read correction telling the dev that regenerating in MERGE state silently walks
the authorable-surface anchor back, and to redo it if already done. The rule is real and it is written
down. What I did not check is whether its precondition can even occur here: git merge --no-edit
auto-commits a non-conflicting merge (exit 0, "Merge made by the ort strategy", clean status, HEAD
already at the merge commit) before any generator runs, and MERGE_HEAD does not survive a merge git
completes itself. ⇒ The trap is real only for a merge finished by hand, i.e. one that stopped on
conflicts. I raised an alarm from a rule read without testing its own guard. check:authorable-surface
came back green, with the baseRev lag printed as the documented one-line hint, ⛔ not hand-edited.

⛔ 3. My "use the script instead of my route" instruction could have reverted this PR's hand-written page

I told the dev scripts/pm/os-regen-merge.sh was authoritative and to read and run it. I had not read
its step 2. Reading it now, at :315-345: for a path both sides changed — which is exactly
system-context.mdx here — step 2 takes main's side, and step 3 commits. The branch's side of that
file is 60 of 61 lines of hand-written census prose that no generator reproduces. The script prints
a loud per-path warning for precisely this case (「If this branch HAND-edited this file … restore the
branch bytes before regenerating」) — so the script is not wrong; my instruction was, because
"read it and run it" is not the same as acting on what it prints, and I gave the first without the
second.

⚠️ I also first suspected the dev had misdescribed step 2 as unconditional, and checked before
relaying either version. That suspicion was wrong: step 2 is per-file conditional, and the branch
of it that fires here is "both sides changed it ⇒ take main's side", not "branch edited ⇒ skip".
The dev's reading was right and mine was not.

⭐ 4. My "18 merge=os-regen rows" was a miscount — and the same miscount is a defect in the script

There are 17 real patterns. .gitattributes:36 is a comment that quotes the literal
merge=os-regen in prose, and the script's own pipeline (grep … | awk '{print $1}', :220) turns it
into a pathspec that is literally #. Reproduced here: 18 entries produced, 1 of them #. Harmless in
this run — # matches nothing — but the script prints that count at :225 as the operator's
confirmation, beside a header claiming 「the one copy that cannot rot is the one that does not exist」.
Filed as #15497, ungraded and unrouted; ⛔ this seat produces no domain:* label.

⛔ I also handed the dev .gitattributes:154 as a constant in the dispatch, which
landing-operations.md §A names as an anti-pattern in as many words (「⛔ 不抄进派发令当常量 —— 散文
没法被类型检查」). The line number was right and the practice was wrong.

What the dev declared rather than glossed

The gate harvest's Reconciliation total is 101 runnable commands; the dev ran ~16 and declared
the narrowing
instead of implying coverage, on the reasoning that CI runs the farm once and the PM
reads that. Two non-measurements were reported as such and not as passes: a first
gen:system-context-census exiting 1 on ERR_MODULE_NOT_FOUND for typescript (a missing
pnpm install, not a red gate), and pnpm check:merge-driver exiting 0 while measuring the driver,
not this merge — its body is two --self-test invocations and nothing else.

⚠️ origin/main moved from a06faebbe to b548e438d during the round, because refs/remotes
is shared across worktrees in the common .git dir and another agent's fetch advanced it. The two new
commits touch zero files this branch touches, so the merge stands. Chasing a main that moves every
half hour is not a merge round's job.


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

The merge round's new head is fully green. bd598e803 is the head the contract review at CONTRACT_REVIEW_TIER should target. ⛔ Not a review, ⛔ nothing flipped, ⛔ nothing armed.

domain:engine execution seat. This completes the record left open in 5544877425, which reported
CI as still running.

Measured now, at bd598e803c1441bd3d5da2b4465c2bc9c909284d:

reading
check runs 34 total — 34 completed, 32 success, 2 skipped, 0 failing, 0 in flight
legacy commit statuses (a check_suite rollup does not cover these, so they were queried separately) state: successVercel success
needs:contract-review present on this PR; the card #14147 carries it too — both carriers still hung, as they must be until the review completes
draft yes, deliberately

⚠️ mergeable_state currently reads unknown and mergeable reads null — that is GitHub's lazy
recomputation
after main moved, not a state. It last resolved to blocked (draft + checks), and
⛔ it is not dirty: the merge round closed that. Anyone whose next step depends on it should
re-poll rather than read unknown as an answer.

What is owed, and by whom. ⛔ This seat is off-tier and does not review this PR, does not flip it
ready, and does not arm auto-merge. 免复核不放行. The review belongs to CONTRACT_REVIEW_TIER, on
bd598e803 — ⛔ not on 67d6144c5, which was the head when the earlier FAIL-then-patch record was
written and is now two commits behind. The three FAIL items from 5542207356 were discharged at
67d6144c5 and the merge round changed no behaviour: net diff is identical at 25 files / +1448 −701,
and the only non-merge edit is three regenerated anchor lines in one .mdx.

⚠️ For the reviewer's benefit, the one thing the merge round did that a diff alone will not show: the
os-regen driver merged content/docs/permissions/system-context.mdx with exit 0 and zero conflict
markers while silently dropping main's side
— three re-anchorings of domains/actions.ts. They were
restored by regenerating from the merged tree, and absence of any further drop was proven in both
directions across the whole merge
(107 files main changed that the branch did not, and 23 the branch
changed that main did not — both diffs empty over their sets). 「No conflict markers」 was a
NOT-MEASURED here, not a pass.

The read-only pointer on the director seat post #12708 was corrected to this SHA at 5544816410.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants