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
Conversation
… 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
…gine-insert-readonly-strip
…, 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
…gine-insert-readonly-strip
… not by an issue id Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…gine-insert-readonly-strip
…g origin/main Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 5 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…gine-insert-readonly-strip
…eleased package list Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
|
Contract review at Short form: Stays draft, 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
Patch round accepted — all three FAIL items discharged, verified by this seat. ⛔ Back to
|
| 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
|
FAIL item 3 is now discharged — all six Measured at
⇒ All three items of the FAIL ( ⛔ Unchanged: Clause ② is Generated by Claude Code |
This PR is now
|
| 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 |
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
…gine-insert-readonly-strip
…/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
Merge round returned — new head
|
| 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 (404→414
twice, 411→421) 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.
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
The merge round's new head is fully green.
|
| 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: success — Vercel 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.
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
Fixes #14147
readonlyis now enforced in-engine on INSERT for non-system callers, exactly asit 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-zhuangandhotlong, human merge).What the change is
engine.insertgains a static-readonlypass beside the runtime-owned one italready had, inside the same
if (!opCtx.context?.isSystem)branch, calling theSAME
stripReadonlyFieldsthatengine.updatecalls, and reporting through thesame channels:
readonlyStripWarningatwarn,onFieldsDroppedunder reasonreadonly,strictReadonlyWritesrefusing withERR_READONLY_FIELD_REJECTEDbefore any driver dispatch. The boundary copy is deleted, not kept as a
second implementation.
Three consequences the card asked for, all discharged:
engine.insertdirectly no longer writes theread-only column;
create_record'sonFieldsDroppedwiring starts receiving readonly drops —driven end to end, evidence below;
assertReferencesResolve's doc sentence is true again.A2.2 — every
stripReadonlyForInsertcall site, enumerated before deletingThe definition was at
packages/metadata-protocol/src/protocol.ts:1794. It hadfive call sites, all in that one file — the card's "at least six" counts the
definition and/or the prose references:
protocol.ts:10526createDataonFieldsDropped(already wired here) carries the dropprotocol.ts:10623cloneDataoverridesare still applied BEFORE the insert, so a smuggled readonly key is still judgedprotocol.ts:11746batchData(createrow)onFieldsDroppedper row (it forwarded none before)protocol.ts:12004createManyDataprotocol.ts:12062insertManyDataThree further mentions in the same file were prose (
:1902,:12069) or thedefinition itself. Deleted with it:
warnPreserveAuditIgnoredOnInsert(movedinto the engine as
preserveAuditIgnoredOnInsertWarning) anddiffDroppedFields, which existed only to reconstruct the ingress strip from abefore/after payload diff and is now dead.
Command and output:
Every non-CHANGELOG prose reference outside
protocol.tswas corrected in thisPR (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-CHANGELOGreferences to the deleted symbol remain except the two that name it as deleted.
Zone 2, item by item
A2.1 — anchors: CONFIRMED.
engine.insertatengine.ts:9724;stripReadonlyFieldsimported at:196; the doc sentence at:5939-5940("like every other write-path guard in this engine (
stripReadonlyFields,stripReadonlyForInsert)"); update-path call sites at:11389and:11569;the lint premise at
validate-readonly-action-writes.ts:57and:154. All fivelocated by symbol and all five matched. One addition the table did not have: a
SECOND false sentence in
assertReferencesResolveat:5983-5984("and thecreate 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, aswarned). 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:
ctx.apiisql.createContext(buildActionExecutionContext(ec))and that is
{ ...ec, isSystem: true }— the rule's own header measures thisand
packages/objectql/src/engine-repo-execute-elevation.test.tspins it. Sothe 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;readonlyWhenstill 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
insertstill keeps both values, andflagging 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 thatneither 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.
validate-readonly-flow-writes.ts(create_record) andvalidate-readonly-hook-writes.ts(ctx.api.insert) — which run NON-elevatedand 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_recordDOES fire now: CONFIRMED end to end. Not a unit teston the strip — a real
ObjectQLover a recording driver, registered as thedataservice of a realAutomationEngine, running a real flow(
packages/services/service-automation/src/builtin/create-record-readonly-drop.test.ts):The first case asserts
creates[0]reaches the driver withoutcompleted_at,and that
listRuns('seed')[0].steps.find(nodeId === 'mk')carriesstatus: 'success'with exactly one warning containingcreate_record(duly_task)andcompleted_at. The second asserts arunAs: '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:
engine.insertalreadystrips
autonumberviastripRuntimeOwnedFieldsunder the WIDERpreserveAuditwhitelist a historical import needs. The new static pass runsover
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 injectsreadonly: trueonto every one, sostripReadonlyFieldswould call it an author-declared lock).preserveAuditis NOT forwarded on the create path. The 2026-08-08ruling narrowed that exemption to UPDATE and left
isSystemas the createside's only one. Honouring it here — which reusing the update call shape
verbatim would have done — would hand a non-system
treatAsHistoricalimportthe 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).managedBy, thesys_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_bywrite working — a direct, non-system
engine.insertcaller.Two behaviours the move changed on purpose, both stated in the changeset:
beforeInserthooks, so it inherits the engine'sguards: 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;
defaultValueis re-derived, so a forgedapproval_statusstill becomesdraftrather than NULL — the ingress copygot that for free by running before
applyFieldDefaults; running after thehooks means asking for it explicitly.
The
warnline is also now verb-aware: on a create it says "the create is beingCOMMITTED WITHOUT IT", names
beforeInsert, and drops thepreserveAuditremedy, which cannot work there. Offering a remedy that would not have worked
is the defect already removed once from that message.
packages/spec/src/data/field.zod.tsThis diff edits one file on
domain:spec's single-owner surface, and thechangeset releases that package (
'@objectstack/spec': patch), so it is calledout here rather than left as a diff line.
RUNTIME_OWNED_FIELD_TYPES. It describedthe 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 namesstaticReadonlyInsertSubject, whichis where that exclusion lives after this PR.
schema, no behaviour. The
patchlevel follows from that under thebump-level rule ("a
fix(that changes no public surface stayspatch").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.
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 tooktest/task-recurrence.test.tswent red on484cec193: 5 failed / 101 passed.The card's own consequence (1) is the cause — a non-system caller reaching
engine.insertno longer writes a read-only column — and the suite asserted theold contract. Two independent causes, not one:
engine.update) were NOT downstreamof the insert failure. The fixture seeded
completed_date—readonly: true,server-owned — on CREATE as a non-system caller; that seed is now stripped, so
the stored row held NULL and the object's
completed_date_requiredrulerefused the later completion. ⭐ And the seed was load-bearing only in this
file, for a reason worth stating: the harness claims to boot "the same
harness
test/task-completion-trigger.test.tsboots" and bound no hooks atall, so the app's own
beforeUpdatecompletion stamp (task.hook.ts, theexamples/app-todo: a normal user can never mark a task complete —
completed_dateisreadonly(stripped on update) andcompleted_date_requiredthen refuses the write, so the app's owncompleteTaskaction always fails #7036 remedy) never ran here. Its sibling deleted the identical create-seedwhen that stamp shipped, and drives this exact write shape green today.
engine.insert) is a fixture that must STARTfrom an already-completed row — there is no transition to stamp on.
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:
bootTodoKernelnow binds the app's owntaskHook, which its own docblockalready claimed it did. Both completion cases therefore travel the app's real
user path instead of around it.
completed_dateCREATE-seed is dropped from those two cases.context.isSystem— the remedythis PR's changeset names, and the answer the ruling gives for seeding a
server-owned column at create time.
completed_dateisreadonly(stripped on update) andcompleted_date_requiredthen refuses the write, so the app's owncompleteTaskaction always fails #7036measurement table keeps its historical fourth row (
insert already-completed (user ctx): OK) with a note that it is dated evidence and no longer a liveescape.
CHANGELOG.mdis left alone — past tense, correct as history.pnpm --filter @objectstack/example-todo test→ 4 files, 106 tests, 0 failed.Composition with #15363, which landed on
mainmid-flight65846bc46(#15363) also editspackages/metadata-protocol/src/protocol.ts. Aclean text merge is not evidence that two changes compose, so this was read
rather than assumed. They compose, and the argument is structural:
toRowApiErrorand a newisEngineDuplicateRecordEnvelopehelper (post-merge lines 1879–1925) — thefailure arm, mapping a caught
DuplicateRecordErrorto theUNIQUE_VIOLATIONwire spelling on a failed row;try(case 'create':),where the engine's
onFieldsDroppedpopulatesdroppedFieldson a row thatwrote. 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 noprotocol create face passes it:
Even had one,
ReadonlyFieldRejectedErrorfails their two-part gate(
code === 'DUPLICATE_RECORD' && name === 'DuplicateRecordError'). Measured, notinferred:
@objectstack/metadata-protocoland@objectstack/restare both greenon 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 purelyadditive 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"):
@objectstack/objectqlengine.insertdoes something new with a caller-supplied readonly field — published behaviour@objectstack/metadata-protocol@objectstack/service-automationcrud-nodes.ts; theonFieldsDroppedchannel is unchanged, only its traffic is new@objectstack/lintindex.tsdeliberately does not re-export — so no public surface widens@objectstack/specexamples/app-todoisprivate: true, so it releases nothing and takes no entry;packages/rest's only change is a test file.majorstays refused during the launch window, so breaking-ness is carried by theBREAKING banner plus the ADR-0087 disposition:
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.jsonor the upgrade guide; the remedy is application code, not a metadata migration),
and separately disposes of the retirement candidate on the measurement
(
stripReadonlyForInsertwas a bare module-privatefunction, absent from thatpackage's
index.ts, which itsexportsmap makes the only path in).node scripts/check-adr-0087-registration.mjsexits 0.Tests
Union run after the final commit, at⚠️ The suite list is now
67d6144c5.derived from
turbo ls --affectedwithout droppingexamples/**andqa/**— that narrowing is what let theexample-todoregression above reachCI instead of this worktree, and the contract review named it. Every row below is
a number; ⛔ nothing is reported as "not reached".
@objectstack/objectql@objectstack/metadata-protocol@objectstack/lint@objectstack/rest@objectstack/service-automation@objectstack/runtime@objectstack/core@objectstack/spec@objectstack/metadata@objectstack/metadata-core@objectstack/platform-objects@objectstack/driver-memory@objectstack/plugin-security@objectstack/plugin-auth@objectstack/plugin-approvals@objectstack/plugin-audit@objectstack/plugin-sharing@objectstack/dogfood@objectstack/downstream-contract@objectstack/example-todo@objectstack/example-showcase@objectstack/example-crm@objectstack/example-embed-objectql@objectstack/example-multi-packageand@objectstack/refd-timer-testkitdeclareno
testscript — stated because a--filterthat matches no script exits 0having 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 casesagainst a real
ObjectQL: the card's exact repro inverted (no context,explicit
isSystem: false),onFieldsDropped, the warn line's threecreate-shaped claims,
defaultValuere-derivation, the three exemptions(
isSystem, abeforeInsertstamp, a hook stamp the caller echoed, platformobjects), the neighbouring rules (
preserveAuditrefused-and-warned on createbut still reinstating an autonumber;
readonlyWhenstill INSERT-exempt),strictReadonlyWritesrefusing with zero driver creates and a deliberatelysilent 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.ts— replacedentirely. 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— thecreate-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— mockengine swapped for a real
ObjectQL. A mock cannot strip, so the oldharness 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— thereadonly-lookup narrowing split into its two halves: the platform-object casekeeps its value and reaches the check (fixture renamed
sys_-prefixed, as thereal
sys_metadata_historyis — now load-bearing, not cosmetic), and a newauthor-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 runas its own command with its exit code captured before any pipe. 70 exit 0.
Named:
check:api-surface0 ·check:authorable-surface0 ·check:browser-reachable-entries0 ·check:changeset-gate-self-tests0 ·check:corpus-claim-drift0 ·check:cross-package-test-inputs0 ·check:dispatcher-error-vocabulary0 ·check:doc-anchors0 ·check:doc-authoring0 ·check:doc-security-posture0 ·check:docs-audit-scope0 ·check:docs-redirects0 ·check:docs-single-h10 ·check:dual-build-cjs-loads0 ·check:dual-source-exports0 ·check:durability-log-level0 ·check:empty-state0 ·check:engine-double-contract0 ·check:entry-nameability0 ·check:error-code-casing0 ·check:error-code-provenance0 ·check:error-status-conformance0 ·check:exported-any0 ·check:filter-alias-parity0 ·check:liveness0 ·check:llms-txt0 ·check:nul-bytes0 ·check:partof-closing-keyword0 ·check:pm-skill-ratchet0 ·check:published-files0 ·check:published-readme-exports0 ·check:skill-examples0 ·check:spec-changes0 ·check:strictness-ledger0 ·check:test-source-alias0 ·check:type-source-resolution0 (plus theremainder of the derived list).
Convention-scoped, invisible to the deriver by construction, asked separately
and run:
check:system-context-census--fixrepaired the line-anchor rot but not the rest: deletingstripReadonlyForInsertremoved 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-coveragecheck:type-check-debtNOT MEASURED (exit 3 or a prerequisite refusal) — reported as such, never as a pass:
check:dual-build-cjs-loadscheck:skill-examplespackages/client-reactbuilt; built and re-ran to 0check:published-readme-exportscheck:api-surface,check:dual-source-exports,check:entry-nameability,check:exported-any,check:browser-reachable-entriespackages/spec/dist; built spec and re-ran to 0check:react-declaration-paritycheck:docs-audit-scopepackages/spec/.examples-build/**artefacts left bycheck:skill-examplesaskind=contractroute sources. Removing that directory greens it. Root cause measured and posted on the existing card #15328 rather than filed againThe system-context census, in detail
content/docs/permissions/system-context.mdxis the one artefact this PR and#15319 both move, so it is spelled out.
node scripts/check-system-context-census.mjs --fixwas 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:
That is a real, ruled consequence: row 21 ("
readonlystrip bypassed — INSERT(protocol ingress)", metadata-protocol) cited the
if (context?.isSystem)lineof 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").
--fix"and nothing else" and never to hand-edit it.
--fixcannot express a censusthat 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 thislane is fencing, so it is flagged here rather than buried. Nothing near
engine.ts:5298was touched;origin/mainwas merged twice (dc46c4ec1, then638ea042d) 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(threeprotocol.tsprose ids the deletion removed) andscripts/engine-double-contract.pinned.json(the rewrittenprotocol.readonly-insert.test.tsfake, whosefindOnenow routes throughassertEngineFindOnePredicate). New prose in this diff carries no issue ids, perthe maintainer ruling of 2026-08-12 the prose-id ratchet enforces.
Out of scope, filed not fixed
premise as a scan gap (see A2.3).
check:docs-audit-scopelocal failure, postedas a comment on the existing card rather than as a duplicate.
content/docs/releases/**untouched.Generated by Claude Code
Generated by Claude Code