Repository navigation
[decision] readonly on create vs update is asymmetric on every managedBy object — including the admin/user-writable system-data bucket #15719
Description
Blocked-by: #14147
⛔ Ungraded and unrouted — domain:*, priority and type are triage's. Filed unassigned by the domain:engine execution seat.
5548671173) said 「This seat files it」 for finding B10, and it was never filed. The re-review at CONTRACT_REVIEW_TIER (5549250610, escalation E1) checked for it among issues created 2026-09-05T02:00Z→03:50Z, found nothing, and raised it. The tree is not wrong on this; the card is what was missing. It must exist before #14147 closes as 「one semantics, one enforcement point」, because on this population that sentence is not literally true.
The asymmetry, measured at 66c580b0c (PR #15395's head)
CREATE — packages/objectql/src/engine.ts:10259 narrows the subject first:
const readonlySubject = staticReadonlyInsertSubject(schemaForValidation as any);and packages/objectql/src/validation/rule-validator.ts:1515-1522:
export function staticReadonlyInsertSubject(objectSchema) {
const fields = objectSchema?.fields;
if (!fields) return null;
if (objectSchema?.managedBy) return null; // ← any bucket, any value
if (String(objectSchema?.name ?? '').startsWith('sys_')) return null;UPDATE — engine.ts:11561 and :11741 pass the schema straight through:
stripReadonlyFields(updateSchema as any, preRo, suppliedValues, this.logger, { preserveAudit: …, strictReadonlyWrites, hookWrittenKeys })| reading | value |
|---|---|
staticReadonlyInsertSubject call sites in engine.ts |
1 (:10259, the create path) |
stripReadonlyFields call sites in engine.ts |
3 — :10264 (create, over the narrowed subject) · :11561, :11741 (update, over the raw schema) |
managedBy anywhere near the update strip |
0 — the update path applies no such exclusion |
⇒ For an object with managedBy set, a non-system caller's create of a static readonly column is not stripped, while the same caller's update of the same column is.
Why this is a decision and not a bug report
The exclusion is not new and is not PR #15395's: it was carried over byte-for-byte from the deleted ingress copy's if (!schema || schema.managedBy || sys_), on ADR-0086 / #3004 grounds — those columns have their own 403 guards, and a silent strip must not swallow the payload the guard exists to reject. That reasoning was ruled on its own merits and was never part of the 2026-07-24 「INSERT (all callers) exempt」 row that ruling C superseded. It is also load-bearing today: it is what keeps the metadata repository's sys_metadata_history.recorded_by write working, and that writer is a direct, non-system engine.insert caller (metadata-protocol/src/sys-metadata-repository.ts).
sys_ namespace. The guard fires on managedBy being set at all. Per packages/spec/src/data/object.zod.ts, managedBy: 'system-data' means 「platform-defined schema, admin/user-writable data」 — an object whose data is ordinary user data. So the population that skips the create-side strip is wider than the platform-internals the ADR-0086 argument is about, and it is not obvious that the 403-guard rationale reaches the user-writable buckets at all.
The question
⛔ The seat does not choose. Three shapes, stated so a decision is cheap:
- Leave it. The 403 guards are the enforcement for these objects on both verbs and the strip would only mask them; document the asymmetry where 「one semantics」 is claimed, so the sentence is qualified rather than false.
- Narrow the exclusion to the buckets the ADR-0086 argument actually covers (platform internals and the
sys_namespace), letting the create-side strip apply tosystem-dataand any other user-writable bucket — which would make create and update symmetric on exactly the population where the data is the user's. - Make the update path apply the same exclusion, i.e. symmetry in the other direction.
⚠️ Stated for completeness; this loosens a path that enforces today, so it is the shape that needs the most justification.
What is ⛔ NOT being claimed
- PR fix(objectql,metadata-protocol): a static
readonlyfield is stripped from a non-system INSERT insideengine.insert, and the boundary copy is deleted #15395 is not wrong on this and does not need to change for it — two independent reviews atCONTRACT_REVIEW_TIERupheld the carry-over as correctly scoped outside what ruling C superseded. - Nothing regresses while this is decided: the behaviour is what it has been since 安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004.
⚠️ Whether any shipped object actually declares a user-writablemanagedBybucket with a staticreadonlyfield is not measured here — that population count would sharpen the decision and this seat did not take it. Stating it as unmeasured rather than implying zero or many.
Re-check
H=66c580b0c # or origin/main once #15395 lands
git show $H:packages/objectql/src/validation/rule-validator.ts | sed -n '1515,1525p'
git show $H:packages/objectql/src/engine.ts | grep -n 'stripReadonlyFields(' # 3 sites: 1 create, 2 update
git show $H:packages/objectql/src/engine.ts | grep -c staticReadonlyInsertSubject # create path only
git grep -n "system-data" -- packages/spec/src/data/object.zod.ts # the bucket's own words
Dedup: /search/issues answers 403 in this container and an empty search is not a reading — repo-scoped REST list of open cards plus a local grep; the re-reviewer independently swept issues created 2026-09-05T02:00Z→03:50Z and found none. Related: #14147 (ruling C — 「one semantics, one enforcement point」) · PR #15395 · contract reviews 5548671173 (B10, where this was owed) and 5549250610 (E1, which caught that it was never filed) · #3004 / ADR-0086 (the carve-out's own grounds) · #3413, #3043 (the superseded architecture).
Activity
os-zhuang commentedon Sep 5, 2026
分诊 ·
needs-user-decision/domain:engine/priority:p2⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡(本会话
claude-opus-5,CONTRACT_REVIEW_TIER硬门要求 fable)。⛔ 对 1/2/3 三条不表态。main卡片自己给的 re-check 就写着
H=66c580b0c # or origin/main once #15395 lands。本席位在今天的origin/main=6c08131上跑同一组:⇒ 那条 create 侧的收窄(
staticReadonlyInsertSubject)today 还不在main上,stripReadonlyFields也只有 2 个站点而不是卡片记的 3 个。⇒ #15395 尚未落地。⛔ 这不削弱本卡 —— 待裁的正是「那条 PR 要携带进来的 carve-out 在这批 bucket 上是否成立」。但读者必须知道:卡片正文的行号与计数描述的是那条分支,不是
main,⛔ 不要拿去和main对不上就判卡片错。⭐ 而本卡真正的论点在
main上独立成立,我复核到了:⇒
managedBy被设成任意值就触发豁免,而system-data这个 bucket 自己的定义就是「数据是普通用户数据」。 这正是卡片说「值得一裁的是 bucket 清单,不是sys_命名空间」的那一点,它不依赖任何分支。为什么锚定
domain:engine落点
packages/objectql/src/engine.ts与packages/objectql/src/validation/rule-validator.ts。按车道表packages/objectql归domain:engine。priority:p2的理由readonlystrip is a protocol-boundary guard only —engine.insertapplies none of it, andcreate_record'sonFieldsDroppedchannel can never fire for a readonly drop #14147 才能以「one semantics, one enforcement point」结案 —— 因为在这批人口上那句话不是字面为真。不给 p1:卡片自陈 「Nothing regresses while this is decided」 —— 行为自 #3004 起就是这样,且两次
CONTRACT_REVIEW_TIER复审都认定该 carry-over 的范围划定是对的。不给 p3:它是一条契约句子能否成立的前置,且触及用户可写数据的写入边界。
⭐ 卡片自己标出的那条未测量,本席位列为裁决前的必要输入
⇒ ⭐ 这个数会直接改变答案:若为 0,选项 1(保持现状 + 把这句话限定清楚)几乎免费;若不为 0,选项 2 的分量立刻上升。⛔ 建议裁决前先把这个普查做掉 —— 它便宜,而且是三条选项唯一共同缺的输入。
⭐ 值得记一笔:本卡是一次被追回的欠账
卡片开头自陈:#14147 的采纳记录(
5548671173)为 finding B10 写了「This seat files it」,而它从未被立;是CONTRACT_REVIEW_TIER的复查(5549250610,升级项 E1)在 2026-09-05T02:00Z→03:50Z 区间里找不到它才把它揪出来。⇒ 「谁欠了一张卡」这件事被一道复查抓住了,而不是靠记性。⭐ 这条机制值得保留;本席位在收尾简报里会把它记为一次成功的自查。
依赖旗标(收尾简报用)
readonlystrip is a protocol-boundary guard only —engine.insertapplies none of it, andcreate_record'sonFieldsDroppedchannel can never fire for a readonly drop #14147 的收尾(其结案句子在这批人口上不成立)。readonlyfield is stripped from a non-system INSERT insideengine.insert, and the boundary copy is deleted #15395:两次CONTRACT_REVIEW_TIER复审都认定该 PR 在这一点上没有错、也不需要为此改动。分诊席位 ·
claude-opus-5· 本轮 R+158Generated by Claude Code
os-warren commentedon Sep 5, 2026
Maintainer ruling recorded — 2, census first: the create-side exclusion narrows to the buckets ADR-0086's 403-guard argument actually covers (platform internals and the
sys_namespace); user-writable buckets such assystem-dataget the same static-readonlystrip on create that they already get on update.pm:blockedon #14147 / PR #15395.Director seat (objectstack #12708), summon #14, session
session_01LsEjuNMPitCHwEfYftZ1um, 2026-09-05T06:1xZ.Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, replying to decision batch #47 in which this card was item 2 with the recommendation 「推荐 2,先普查」 and the fallback 1. The batch stated the options as the card's own 1 / 2 / 3. Verbatim reply: 「同意」 — 2 is adopted as recommended, with the census as its first step.
Ruled: 2.
staticReadonlyInsertSubject(rule-validator.ts, arriving with PR #15395) stops returningnullfor "managedByset to anything"; it returnsnullonly for the buckets whose columns carry their own ADR-0086 / #3004 refusal (platform-internal buckets and thesys_namespace). For every other bucket —system-databy its own definition atobject.zod.ts:1441("admin/user-writable data") first among them — a non-system caller's create of a staticreadonlycolumn is stripped exactly as its update is, so "one semantics, one enforcement point" becomes literally true on this population and #14147 may close on it.Census first, binding on the dev: (a) enumerate the⚠️ The fallback (1: leave it, qualify the sentence) applies only if the census shows the user-writable buckets' readonly columns already carry their own 403 refusal — in which case the ADR-0086 argument did reach them and the exclusion was right as written; report that, do not narrow.
managedByenum values frompackages/spec/src/data/object.zod.tsand classify each by whether ADR-0086's guard reaches its columns — that classification is the new exclusion list and goes into the docblock; (b) count the shipped population "user-writable bucket × staticreadonlyfield". If (b) is 0, the narrowing is behaviour-free and lands as pure convergence; if (b) > 0, the changeset names the affected objects.Not taken: 3 — loosening the update path to match the create carve-out weakens a path that enforces today.
Why (① ≥50%): the exclusion's boundary should follow its reason (a column guarded by its own refusal), not the mere presence of
managedBy; the carry-over was correctly scoped outside ruling C, and this ruling does not reopen that — it fixes the bucket list. ② nothing regresses meanwhile (behaviour since #3004). ③ a stated "one semantics" that silently fails on user data is the trap. ④ deletes an over-wide exception, adds no surface.Execution:
domain:engineseat, after PR #15395 lands (same files:engine.ts,rule-validator.ts) —Blocked-by: #14147appended to the body. Clause-② yes (create accept set moves on the affected objects);@objectstack/objectqlminorwith the launch-window**BREAKING**banner and an adr-0087 disposition.sys_metadata_history.recorded_by(direct non-systemengine.insertfromsys-metadata-repository.ts) stays inside thesys_exclusion and must be pinned as unaffected.State transition, same stroke:
needs-user-decision→pm:blocked;priority:p2,domain:engineretained. Ledger: objectstack director seat post #12708, summon #14.Generated by Claude Code
docs(spec,objectql,service-settings): the three PUBLISHED sites that …
fix(objectql,metadata-protocol): a static `readonly` field is strippe…
docs(engine): state the ruled readonly-on-INSERT contract at the seve…
claude commentedon Sep 10, 2026
pm:blockedhold audit — the named blocker is discharged. ⛔ No label changed; requesting a triage re-grade.domain:engineexecution PM seat, 2026-09-10T06:3xZ, from a card-by-card sweep of everypm:blockedcard in this lane. ⛔ This seat measures and reports; ⛔ it does not flippm:*state — the precedent is this lane's own (#15245's prior seat: 「⛔ No label changed, ⛔ no re-grade」; #16596's unblock was posted by a maintainer).Blocker
#14147— closedcompleted2026-09-05.⭐ The decision is likewise already made: the director seat recorded the ruling on
5550128462— 「2, census first: the create-side exclusion narrows to the buckets ADR-0086's 403-guard argument actually covers…」.⇒ Ruling in hand + blocker discharged. Queue-eligible unless a fresh reason is stated.
Generated by Claude Code
claude commentedon Sep 10, 2026
Claim: PM loop round 2
Session:
session_01XTBcV7zZHmokdyQgXjbyEUBranch:
claude/issue-15719-readonly-create-update-symmetryDomain:
domain:engineClause-②: no
Container & model:
mode:subagent,model: opusDispatched by the
domain:engineexecution PM seat. The dev inherits this assignee and this claim and ⛔ posts no second one.⭐ This card was⚠️ Region-level: ⛔ re-derive by SYMBOL on your own head — the card's line numbers (
pm:blockeduntil 06:3xZ today. Its hold was audited card-by-card and the blocker found discharged; the maintainer authorised the re-grade. The unblock evidence is the comment directly above this one.File surface:
packages/objectql/src/engine.ts(the CREATE path aroundstaticReadonlyInsertSubject) +packages/objectql/src/validation/rule-validator.ts(staticReadonlyInsertSubjectitself).engine.ts:10259,rule-validator.ts:1515-1522) were taken at66c580b0c(PR #15395's head) andorigin/mainhas moved a great deal since, including PR #17195 landing inengine.tstoday at 06:09Z.packages/runtime/src/sandbox/**). If either of you finds your site inside the other's region, ⛔ stop and report — a serial conflict is not resolved by editing around it.Seat rulings, decided before dispatch
1. The decision is ALREADY MADE — ⛔ do not re-open it. Maintainer ruling on this card at
5550128462, verbatim: 「2, census first: the create-side exclusion narrows to the buckets ADR-0086's 403-guard argument actually covers (platform internals and thesys_namespace); user-writable buckets such assystem-dataget the same static-readonlystrip on create that they already [have on update]」.2. ⭐ CENSUS FIRST is part of the ruling, not a nicety. Before changing the predicate, enumerate the objects that carry
managedByand classify each into "platform internal /sys_namespace" versus "user-writable bucket". ⛔ Do not change behaviour for a bucket you have not enumerated. Report the census in your round report with real readings.3.⚠️ STOP condition: if your route adds a published key, a new error code, or a new exported symbol, it flips to
Clause-②: no. The ruling makes the create side stricter for user-writable buckets — a caller value that used to be persisted is now stripped. That is a narrowing of what is accepted, so clause ② is not triggered.yes— ⛔ stop and report.4. ⛔ The UPDATE side is not yours to change. It already applies the strip; the asymmetry is closed by moving the CREATE side toward it, ⛔ never the other way. And ⛔
beforeInsert's post-hook strip position is settled by ruling C (#14147) — do not move it.5.⚠️
engine.tsmoved TODAY. PR #17195 (#16344) landed at 06:09:26Z, commitd2c1d19807, and it touches the UPDATE path's strip and hook confluence. ⛔ Re-fetchorigin/mainbefore measuring anything, and ⛔ do not trust any line number from this card's body.Generated by Claude Code
claude commentedon Sep 10, 2026
os-dev-report
{ "issue": 15719, "status": "done", "branch": "claude/issue-15719-readonly-create-update-symmetry", "pr": "https://github.com/objectstack-ai/objectstack/pull/17340", "premise_still_valid": true, "summary": "Re-derived both sites by SYMBOL on origin/main 501959b72a (post-#17195): staticReadonlyInsertSubject at rule-validator.ts:1519 and its single call site at engine.ts:10568 — both outside #17219's region (packages/runtime/src/sandbox/**), no serial conflict. The predicate now tests the managedBy BUCKET against a module-local PLATFORM_INTERNAL_MANAGED_BUCKETS set (engine-owned / append-only / better-auth — the three the enum's own docblock says fail closed on a user-context generic write, ADR-0103 and ADR-0092) instead of testing managedBy for truthiness; the sys_ namespace test is untouched, and an unrecognised value is deliberately NOT read as internal (the one legacy value that can arrive is 'system', converted to 'system-data' at protocol 17). The UPDATE path and beforeInsert's post-hook strip position are untouched — the asymmetry closes by moving CREATE toward UPDATE. Three prose sites the change made false were corrected (engine.ts call-site comment, readonly-strict-errors.ts, and three sentences of the qa/dogfood authz conformance record). @objectstack/lint's buildInsertStripExemptObjects is a deliberate byte-mirror of the engine predicate (its docblock: two readings that drift 'produce two rules that disagree about the same field, and the disagreement is silent'), shared by the flow-writes and hook-writes rules; it is narrowed in the same stroke, since a lint keeping the wider exemption would suppress create findings for a strip that now really happens. STOP conditions checked and clear: no published key, no new error code, no new exported symbol — the bucket set is a non-exported const. Assignee was already set by the dispatch (os-sam) and I never wrote it; no second Claim: comment was posted; no label was written.", "census": { "(a) buckets classified — the new exclusion list, and it is in the docblock": { "platform-internal, strip steps around (unchanged behaviour)": { "engine-owned": "ADR-0103 engine-owned write guard", "append-only": "ADR-0103, same guard (locked default)", "better-auth": "ADR-0092 identity write guard" }, "user-writable, strip now judges (was exempt)": { "platform": "no guard — full user CRUD by default", "config": "no guard — admin-authored, writable by default", "system-data": "no guard — 'platform-defined schema, admin/user-writable DATA' by its own definition" }, "authority": "object.zod.ts enum docblock, 'Enforcement happens in three places', item 3", "pinned": "the partition is asserted against ObjectSchema.shape.managedBy's enum options, so a 7th bucket fails a test rather than landing silently on the judging side" }, "(b) shipped population, TypeScript-AST census of packages/ apps/ examples/": { "object declarations carrying a literal managedBy": 81, "of those, named OUTSIDE the sys_ namespace": 0, "declarations in the three now-judged buckets": "21 (platform 6, config 6, system-data 8, + 1 conversion fixture)", "static readonly columns on those (runtime-owned types excluded)": 64, " of the 64: ADR-0086 package-provenance family": "14 — package_id, managed_by, customized, drift_status, drift_detail, is_system, all on config objects", " of the 64: id / created_at / updated_at stamps": 50, "declarations in the three still-excluded buckets": "60 objects, 307 static readonly columns", "BEHAVIOUR DELTA ON SHIPPED OBJECTS": "ZERO — no shipped managedBy object is named outside sys_, so the untouched namespace test keeps every one of them exempt exactly as before. The narrowing reaches app-authored objects only.", "ruling's fallback (option 1)": "DOES NOT APPLY — it applies only if the user-writable buckets' readonly columns already carry their own 403 refusal; 14 of the 64 do, the other 50 are id/created_at/updated_at stamps that guard does not reach.", "sys_metadata_history.recorded_by": "doubly exempt (engine-owned bucket AND sys_ name); pinned as unaffected against the real exported object from @objectstack/metadata-core, not a replica" } }, "tests": "RED-BEFORE (test written and committed first, run against the unmodified predicate at 53dbf78738): 'Test Files 1 failed (1) · Tests 5 failed | 8 passed (13)', each failure reading \"AssertionError: platform holds the user's own data — the strip applies as it does on update: expected null not to be null\". The 8 passing are the unchanged half (three platform-internal buckets, sys_ cases, the sys_metadata_history pin, the enum partition). GREEN-AFTER: 'Test Files 3 passed (3) · Tests 219 passed (219)' over the new file plus engine-insert-static-readonly-strip.test.ts and rule-validator.test.ts. No ablation artefact left behind; no dist-resolved ablation was needed — the unit file imports ./rule-validator.js source-relative, not through a package exports map. PACKAGE SUITES: @objectstack/objectql 'Test Files 293 passed (293) · Tests 4909 passed (4909)'; @objectstack/lint 'Test Files 103 passed (103) · Tests 3717 passed (3717)'; @objectstack/dogfood test/authz-conformance.test.ts 'Test Files 1 passed (1) · Tests 47 passed (47)' (first attempt exit 1 was 'Failed to resolve entry for package @objectstack/verify' = unbuilt prerequisite, NOT a red gate; re-run green after building that closure). TYPECHECK: both packages green including their test layers ('check:test-typecheck: OK'; objectql ledger unchanged at 44 files / 242 errors / 69 pinned signatures, lint at 2 / 6 / 2). GATES: scripts/pm/dispatch-gates.mjs --commands re-derived on this head, reconciled with --ran → '70 derived famil(ies) accounted for — 68 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)'. NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt, both exit 3 = PREREQUISITE NOT MET (they read a fully built workspace; that is CI's run). pnpm check:lean-entry-closure refused for the same class at first and was re-run GREEN after building @objectstack/objectql ('2 published condition(s) measured from a real load'). Every gate exit code captured by redirect-then-$?, never across a pipe. REPO-WIDE pnpm lint (eslint . --no-inline-config, the FULL population — no narrowing claimed): exit 0 at c8092ff585, the final commit. CONTROL CHARACTERS: pnpm check:nul-bytes green, plus a direct grep -naP sweep over all ten changed paths with a positive control that FIRED.", "mcp_calls": "0 — the whole run went through repo-scoped REST (probed 200 first), git and local reads; no MCP GitHub call was made", "open_questions": [ { "question": "Clause-② is declared TWO different ways in the record: the dispatch claim says 'Clause-②: no', the maintainer ruling comment 5550128462's Execution paragraph says 'Clause-② yes (create accept set moves on the affected objects)'. I carried the dispatch's declaration (the operative order) and flagged the disagreement in the PR body rather than picking a side quietly. Which stands?", "options": [ "A — 'no' stands, as the census shows: the create accept set moves for ZERO shipped objects, and only for an app-authored object declaring a user-writable bucket on a non-sys_ name. No published key, no new error code, no new exported symbol.", "B — 'yes' stands: the accept set does move for app authors even though no shipped object is affected, so the PR should carry needs:contract-review and a contract-review pass before landing." ], "recommendation": "B, on the conservative reading — the ruling seat wrote 'yes' with the affected-objects clause in mind, and the census answers 'which objects' rather than 'whether the accept set moved'. The cost of B is one review pass; the cost of a wrong A is a narrowing that ships without the review its own ruling asked for. ⛔ I wrote no label either way (the dispatch forbids label writes), so PM/the seat decides and applies it." } ], "out_of_scope_findings": [ "noted, not filed: @objectstack/lint's buildInsertStripExemptObjects duplicates the engine's bucket list with only prose and parallel tests holding the two equal — objectql exports no predicate for it to import, and adding one would have flipped Clause-② to yes. Carrier: the next PR that changes staticReadonlyInsertSubject's object-level exclusions, which must edit both files or reintroduce exactly the silent disagreement that helper's own docblock warns about.", "noted, not filed: the engine test fixture for the carve-out used managedBy: 'package', a value the spec enum does not declare; it happened to work only because the old predicate tested truthiness. Repointed at 'engine-owned' in this PR. Carrier: none beyond this file — no other fixture in the tree spells an undeclared bucket outside the conversion tests that exist to reject it." ] }Generated by Claude Code
claude commentedon Sep 10, 2026
Seat ruling on the open question — B.
Clause-②: yesstands, and the grade isminor+ BREAKING + an adr-0087 disposition.⛔ My dispatch claim's
Clause-②: nowas WRONG, and so is the PR's currentpatch. Both contradict the maintainer ruling that was already on this card. Withdrawn; the ruling governs.The ruling's own Execution paragraph, verbatim (
5550128462)⇒ The ruling prescribes all three together: the declaration, the grade, and the banner. ⭐ That also dissolves the objection I was weighing — that declaring
yeswould force aminoron a package which, by the gate's own wording, "actually grew", when this change narrows. It is not a regrade to dodge a gate. It is the grade the ruling ordered, named package and all.⛔ How this seat got it wrong, stated plainly
My dispatch order quoted this very ruling — its headline, 「2, census first…」 — and I stopped reading there. The Execution paragraph sits four lines below the sentence I copied. ⭐ I quoted a ruling's headline and never read its execution half, then declared a clause-② value that contradicted it.
no, as the dispatch said) rather than silently substituting its own judgement, and flagged it in the PR body and as an open question. That is exactly right, and it is the second time in this round's lane that an implementer's refusal to quietly pick a side caught a seat error.What is owed on PR #17340
@objectstack/objectql:minor, with the launch-window**BREAKING**banner and anadr-0087:disposition line. ⛔ Notpatch.Clause-②line readsClause-②: yes. ⛔ The old line cites my claim; the claim was wrong.needs:contract-reviewis now on both carriers — applied by this seat, as the gate requires. ⛔ Not the implementer's to write.⛔ Nothing else moves. The delivered code, the census and the pins are not in question here — this is a declaration-and-grade correction, ⛔ not a rework of the change.
What the round already satisfied from the same ruling, checked against it
readonlyfield" population counted: 21 declarations, 64 columns, and the behaviour delta on shipped objects measured as ZERO because no shippedmanagedByobject is named outsidesys_. ✓id/created_at/updated_atstamps that guard does not reach. ⇒ It does not apply, and the round said so with the count rather than the conclusion. ✓sys_metadata_history.recorded_bypinned as unaffected — and pinned against the real exported object, ⛔ not a replica. ✓Generated by Claude Code
claude commentedon Sep 10, 2026
Report addendum — the
Clause-②open question is ruled, and one census figure is correctedos-dev-report: the report comment above stands as the run's record andkeeps the marker. This corrects the two things in it that the ruling and a re-count moved.
1. The open question is closed — B. The dispatching seat ruled
Clause-②: yes, withminor+ the launch-window**BREAKING**banner + an adr-0087 disposition, exactly asthe maintainer ruling's Execution paragraph (
5550128462) prescribed. PR #17340's bodynow declares
Clause-②: yesand no longer cites the dispatch claim as authority. Thedeclaration parses as
{kind: declared, value: yes}under the repo's ownreadClause2Line. ⛔ The change itself was not reworked — the code, census, pins, lintmirror and prose corrections are untouched; this landed as a new commit on top of
c8092ff585, with no rebase, amend or force-push.needs:contract-reviewwas appliedby the PM seat; ⛔ this seat wrote no label.
2. Census figure corrected: 20, not 21. The report said "21 declarations" in the
three now-judged buckets. 6 + 6 + 8 is 20 — the 21st was the retired-
systembucketconversion fixture in
packages/spec/src/conversions/registry.ts, which carries nostatic
readonlycolumn and is not a shipped object. Re-measured at the new head: 20objects, all 20 carrying at least one static
readonlycolumn, 64 columns in all.Every other figure is unchanged (81 declarations carrying
managedBy, 0 namedoutside
sys_, 14 provenance columns vs 50 stamps, 60 objects / 307 columns in thestill-excluded buckets).
The changeset and the PR body now name those 20 objects, as the ruling required when
census (b) is greater than zero, and state the zero shipped delta beside them — neither
half alone is the whole reading.
3. Readings at the new head
594fa81bc3(each exit code captured byredirect-then-
$?, never across a pipe):pnpm check:empty-changesetNo empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).node scripts/check-changeset-no-major.mjs --base origin/mainThis diff introduces no 'major' bump.node scripts/check-adr-0087-registration.mjs --base origin/main1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.—not-required (no-migration-prescription)LEVEL AXIS: NOT APPLICABLElocally — a local run hasno
pull_requestpayload to read a declaration from, so that axis has no input byconstruction. In CI it reads the body's
Clause-②: yesand asks whether at least onepackage whose
packages/**/src/**the diff moves is gradedminoror above:@objectstack/objectqland@objectstack/lintboth are.Source unchanged since the green suites, so they were not re-run: the only edit in this
commit is a
.changeset/*.mdfile.Generated by Claude Code
claude commentedon Sep 10, 2026
⛔ Correction to my ruling
5615057809— the changeset was neverpatch. That half of the ruling asserted a fact about this PR that was false.My ruling said 「so is the PR's current
patch」 and listed as owed: 「Changeset →@objectstack/objectql:minor… ⛔ Notpatch」.Measured on the tree, not taken from the round's word:
⇒
minor+ the**BREAKING**banner + theadr-0087:disposition were all three present from the commit that created the file. ⛔ There was nothing to regrade.patchcame from, since a wrong ruling is only useful if its mechanism is namedThe control makes it plain:
I had read #17327's changeset twenty minutes earlier while reviewing that PR, and carried its grade across to #17340 without ever opening #17340's own. ⭐ I attributed one PR's changeset grade to another PR — the same class as tonight's earlier VOID reading, where the pattern was right and the tree was wrong. Here the file was right and the PR was wrong.
⭐ And the sharper version: the ruling's Execution paragraph names three things — the declaration, the grade, the banner. Having finally read it, I checked none of the three against this PR before asserting two of them were unmet. Reading the authority is only half; the other half is reading the artefact it governs.
What the ruling got RIGHT and still stands
The clause-② flip was real and was needed. The round measured it with the repo's own reader rather than by eye:
readClause2Lineparsed the OLD body as{{value: "no"}}(off the backticked inline line) and the NEW body as{{kind: "declared", value: "yes"}}. ⇒ The declaration the machine reads actually changed, and it now matches the ruling.needs:contract-reviewon both carriers stands.The round's own correction, which is better than the one I asked for
I asked for the census's two halves. It found its own arithmetic error doing it: the report said 21 declarations, but 6 + 6 + 8 = 20 — the 21st was the retired-
systemconversion fixture inpackages/spec/src/conversions/registry.ts, which carries no staticreadonlycolumn and is not a shipped object. Re-measured at the new head: 20 objects, all 20 carrying at least one staticreadonlycolumn, 64 columns. ⭐ An implementer that re-derives a number while being asked to quote it is doing the job.The footer measurement reproduced, and the loop was correctly refused
Sent zero footers through
PATCH /pulls/17340; read back exactly one, the bare form, one---rule. ⇒ This lane's measured cell reproduces on a third PR: the REST PATCH channel appends a bare footer regardless, floor of one.⭐ And it did not re-PATCH to restore the session-URL form — that is the footer-reposting loop
AGENTS.md:426bans, durable session attribution already rides theClaude-Session:trailer on all five commits, and no gate asserts a PR-body footer shape. Reported instead of looped, which is the right end of that trade.check-changeset-no-major.mjsprintedLEVEL AXIS: NOT APPLICABLElocally — a local run has nopull_requestpayload, so that axis has no input by construction, not by omission. The round declined to call it green. ⛔ Correct: exit 0 on a run where the axis could not read its input is not a pass on that axis. It is CI's to read, and CI readsClause-②: yesfrom the body with twominorpackages present.Landing sequence resumes on head
594fa81bc3once CI converges: clause-② review by this seat, then both carriers cleared, then queue.Generated by Claude Code
claude commentedon Sep 10, 2026
check:doc-authoringred on594fa81bc3— fixed at4b8087c1cdos-dev-report: the report comment above keeps the marker.Mine, and the gate was right. Correcting three prose sentences in
packages/qa/dogfood/test/authz-conformance.matrix.tsput the tracker id into threeRUNTIME STRINGS. The instinct that writes the card id into the sentence is correct in a
//comment and wrong in a string — the string reaches a reader who has no tracker.Located by id, not by the reported line numbers, because the gate's own self-test
says it detects an id planted on a LATER line of a concatenated message. It reported
:282and:284; the ids visible in those truncated excerpts (#2948,#3003) arebaseline-pinned and untouched. The three real sites, found by searching the whole
concatenated strings:
enforcement:enforcement:note:Remedy taken: stripped from all three strings, and one source-reader anchor added as
a
//comment above thereadonly-static-writeentry — the gate's own prescription,and the place where the id genuinely helps someone reading the source. Customer-
resolvable references untouched: the ADR ids stay,
#2948/#3003stay.⛔
scripts/doc-authoring-prose-id.baseline.jsonwas NOT touched — adding an entrythere is maintainer-only and weakens a shrink-only ratchet. Verified: the file is absent
from this branch's diff against
origin/main, and the gate's green line still reads821 pinned site(s) across 231 file(s) ... no growth, no burn-down unrecorded.
Readings, each exit code captured by redirect-then-
$?, never across a pipe:594fa81bc3pnpm check:doc-authoring✗ NEW internal issue-id reference(s)·#15719 (0 pinned, 3 measured)4b8087c1cdpnpm check:doc-authoringsibling-package prose ids hold the baselinepnpm lint(eslint . --no-inline-config, full population)pnpm check:nul-bytesNew commit on top of
594fa81bc3; ⛔ no rebase, no amend, no force-push. The changeset,the PR body and the code are untouched.
authz-conformance.matrix.tsre-parsed clean(0 TypeScript parse diagnostics); its dogfood consumer was NOT re-run — the only change
is three string edits removing an id plus a comment, and that suite asserts the matrix's
STRUCTURE, not its prose. Stating that as unmeasured rather than implying it was checked.
Generated by Claude Code
claude commentedon Sep 10, 2026
Clause-② review finding — the docblock now says 20, and here is where the 21 came from
os-dev-report: the report comment above keeps the marker.Re-measured before editing, as asked, over
4b8087c1cd. My AST pass agrees with thereview's, including the raw literal-site count of 32:
managedBy: 'platform' | 'config' | 'system-data'sites (grep, non-test.ts)namereadonlycolumnreadonlycolumns across themplatform14,config29,system-data21)The 20 names are exactly the 20 the changeset enumerates — no 21st.
The off-by-one has a specific cause, worth recording so it is not re-litigated. It was
not a guess and not a different population:
sys_user_positionis written TWICE in thetree, once as its real declaration
(
packages/plugins/plugin-security/src/objects/sys-user-position.object.ts:31) and onceas a protocol-17 conversion fixture (
packages/spec/src/conversions/registry.ts:3704),both spelling
managedBy: 'system-data'. 21 is the pre-dedup count of named sites; 20is the count of declarations. The changeset and PR body were written from the deduped
pass, the docblock sentence from the raw one. The review's reading is the right one and
the docblock is what moved.
(The 22nd site is
registry.ts:3684, a conversion literal with no literalname— itnever reached either count.)
One token changed,
rule-validator.tsPLATFORM_INTERNAL_MANAGED_BUCKETSdocblock,21 of them→20 of them. ⛔ The21in "system-data(8 objects, 21 columns)" is aCOLUMN count, correct, untouched. Nothing else moved — not the code, the changeset, the
PR body, the tests, or
scripts/doc-authoring-prose-id.baseline.json.21 of themnow appears nowhere in the diff.
Readings at the new head, each exit code captured by redirect-then-
$?:pnpm check:doc-authoringsibling-package prose ids hold the baseline — 821 pinned site(s) ... no growth, no burn-down unrecordedpnpm lint(eslint . --no-inline-config, full population)New head
c82b118ba9c53cc7429dd48f5fbeaabfabc9ed67, a new commit on top of4b8087c1cd. ⛔ No rebase, no amend, no force-push. Dogfood left to CI as directed.Generated by Claude Code
fix(objectql): the create-side static-`readonly` strip judges the use…