Repository navigation
fix(objectql): the create-side static-readonly strip judges the user-writable managedBy buckets (#15719) - #17340
Conversation
…re narrowing it Red-before for the #15719 ruling: 5 failed | 8 passed against the current predicate, which returns `null` for `managedBy` set to ANYTHING. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ts ADR-0086 covers `staticReadonlyInsertSubject` returned `null` for `managedBy` set to ANYTHING, so a non-system caller's CREATE of a static `readonly` column on a user-writable bucket was not stripped while the same caller's UPDATE of the same column was. The exclusion now follows its reason: the `sys_` namespace, plus the three buckets whose columns carry their own fail-closed refusal (`engine-owned` / `append-only` via ADR-0103, `better-auth` via ADR-0092). `platform`, `config` and `system-data` are judged on create exactly as they are on update. @objectstack/lint mirrors the same predicate for its create-verb findings and is narrowed in the same stroke, so the two cannot disagree about the same object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…e-side exclusion Three sentences in the authz matrix stated the create-side strip steps around "a sys_-prefixed or managedBy object". That is no longer true of the three user-writable buckets, and the matrix is the record other seats read to decide whether a posture is enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 21 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 c051f9224cbb9c3692aa368041b99cc4d87d52e0 && git checkout c051f9224cbb9c3692aa368041b99cc4d87d52e0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 706ad0fcc4ed4f76da4e22255bd4fb0f2edae2bc c82b118ba9c53cc7429dd48f5fbeaabfabc9ed67 && git checkout -B drift-repro 706ad0fcc4ed4f76da4e22255bd4fb0f2edae2bc && git merge --no-ff c82b118ba9c53cc7429dd48f5fbeaabfabc9ed67
node scripts/docs-audit/affected-docs.mjs --json 706ad0fcc4ed4f76da4e22255bd4fb0f2edae2bc |
…jects and the zero shipped delta The maintainer ruling required the changeset to name the affected objects when census (b) is greater than zero. It is: 20 shipped objects in the three now-judged buckets carry 64 static readonly columns. The other half stays stated beside it — none of them is named outside `sys_`, so the untouched namespace test keeps every one exempt and the shipped behaviour delta is zero. Neither reading is the whole one on its own. The earlier text said 21; 6 + 6 + 8 is 20, and the 21st was the retired-`system` conversion fixture, which carries no readonly column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
`check:doc-authoring` was red on three new internal issue-id references I put inside the conformance matrix's `enforcement` and `note` strings while correcting their prose. The gate is right: a runtime string reaches a reader with no tracker, so the id belongs in a `//` comment beside the entry or nowhere. Stripped from all three strings; one source-reader anchor added as a comment above the entry. Customer-resolvable references are untouched — the ADR ids stay, and so do the baseline-pinned ids already in those strings. ⛔ scripts/doc-authoring-prose-id.baseline.json is NOT touched: adding an entry is maintainer-only and would weaken a shrink-only ratchet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…on backs The `PLATFORM_INTERNAL_MANAGED_BUCKETS` docblock said 21 shipped objects in the three judging buckets while the changeset and the PR body said 20 and backed it with an enumeration that adds up (platform 6 + config 6 + system-data 8). Re-measured over the tree: 22 literal sites in those buckets, 21 of them inside an object literal that also declares a literal name, 20 DISTINCT declarations — `sys_user_position` is written twice, once as its real declaration in plugin-security and once as a conversion fixture in `spec/conversions/registry.ts`. The docblock's number was the pre-dedup count. The names are the 20 the changeset enumerates, and they hold 64 static readonly columns. ⛔ The 21 in "system-data (8 objects, 21 columns)" is a COLUMN count and is correct; it is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Fixes #15719
Clause-②: yes
Per the maintainer ruling's Execution paragraph (
5550128462), verbatim:「Clause-② yes (create accept set moves on the affected objects);
@objectstack/objectqlminorwith the launch-window**BREAKING**banner and an adr-0087 disposition.」The ruling prescribes the declaration, the grade and the banner together, so the
minorhere is the grade it named, not a bump taken to satisfy a gate. The create accept set
does move — for an app-authored object declaring a user-writable bucket on a name
outside
sys_— even though, per the census below, no SHIPPED object changes behaviour.The route adds no published key, no new error code and no new exported symbol: the
bucket list is a module-local
constinrule-validator.ts, deliberately not exported(which is also why
@objectstack/lintnarrows a mirror rather than importing one).What was asymmetric
staticReadonlyInsertSubject(packages/objectql/src/validation/rule-validator.ts)returned
nullformanagedByset to anything, so the CREATE path judged nostatic
readonlycolumn on such an object, while the UPDATE path passed the rawschema to
stripReadonlyFieldsand stripped it. For amanagedByobject anon-system caller's create of a static
readonlycolumn was not stripped while thesame caller's update of the same column was.
The exclusion itself is sound — it was carried over from the deleted DataProtocol
ingress copy 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. What was wrong
was the bucket list.
managedBy: 'system-data'means "platform-defined schema,admin/user-writable data" by its own definition, and
object.zod.tssays in thesame breath that it "carries no such guard; its writes are adjudicated by the
delegated-admin gate / RLS / permission sets".
The ruling this implements
Maintainer ruling on the card at
5550128462, verbatim:Census (a) — every
managedBybucket, classifiedThe classification axis is
object.zod.ts's own enum docblock, "Enforcement happens inthree places", item 3: which buckets have an engine write guard that fails closed on a
user-context generic write.
engine-ownedappend-onlybetter-authplatformconfigsystem-dataThat table is the new exclusion list and it is in the docblock, as the ruling required.
It is pinned against
@objectstack/spec's enum by a test, so a seventh bucket failsthere instead of landing silently on the judging side.
Census (b) — the shipped population, measured on this head
Every
.tsobject declaration inpackages/,apps/andexamples/carrying aliteral
managedBy, parsed with the TypeScript AST (field definitions resolved throughthe
Field.*(...)builders, options object read from whichever argument carries it):managedBysys_namespacereadonlycolumnplatform6,config6,system-data8)readonlycolumns on those (runtime-owned types excluded)package_id,managed_by,customized,drift_status,drift_detail,is_system, all onconfigobjectsid/created_at/updated_atstampsreadonlycolumnsThe ruling required the changeset to name the affected objects when (b) is greater than
zero. It is, and they are named there and here:
platform(6 objects, 14 columns):sys_attachment,sys_business_unit,sys_business_unit_member,sys_comment,sys_report_schedule,sys_saved_reportconfig(6 objects, 29 columns):sys_capability,sys_email_template,sys_permission_set,sys_position,sys_sharing_rule,sys_webhooksystem-data(8 objects, 21 columns):sys_approval_delegation,sys_notification_preference,sys_notification_subscription,sys_notification_template,sys_position_permission_set,sys_user_permission_set,sys_user_position,sys_user_preferenceBehaviour delta on the shipped population: zero. Because no shipped
managedByobject is named outside
sys_— every one of the 20 above included — the namespacetest, untouched here, keeps all of them exempt exactly as before. The narrowing reaches
app-authored objects only, which is the population the ruling is about.
real and named above, and an app declaring one of those buckets on its own object gets
the strip. The population alone understates it: not one shipped object changes
behaviour on this release.
The ruling's fallback does not apply. It applies "only if the census shows 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_atstamps that guard does notreach. So the narrowing is the route, not option 1.
sys_metadata_history.recorded_by— seeded by a direct, non-systemengine.insertfrom
sys-metadata-repository.ts— is doubly exempt (engine-ownedbucket and thesys_namespace) and is pinned as unaffected against the real exported object, not areplica.
What changed
packages/objectql/src/validation/rule-validator.ts— the predicate now tests thebucket against a module-local
PLATFORM_INTERNAL_MANAGED_BUCKETSset instead oftesting
managedByfor truthiness. An unrecognised value is deliberately notread as platform-internal: the one legacy value that can still arrive is
'system',retired in protocol 17 (v17: retire the overloaded
managedBy: 'system'bucket — move the 8 admin/user-writable objects to a dedicated bucket #3355) and converted to'system-data'— a judging bucket —so exempting unknowns would exempt precisely the rows that conversion targets.
packages/objectql/src/engine.ts— the create-path comment at the call site, whichsaid "platform objects to their own 403 guards". Prose only; the strip's position
after the
beforeInserthooks is untouched.packages/objectql/src/readonly-strict-errors.ts— the same sentence, which alreadycited this card.
packages/lint/src/validate-readonly-flow-writes.ts—buildInsertStripExemptObjectsis a deliberate mirror of the engine predicate (its own docblock: two readings that
drift "produce two rules that disagree about the same field, and the disagreement is
silent"). Narrowed in the same stroke and for the same reason; a lint that kept the
wider exemption would go on suppressing create findings for a strip that now really
happens. Shared by
validate-readonly-hook-writes.ts, so both rules move together.packages/qa/dogfood/test/authz-conformance.matrix.ts— three sentences of theauthz conformance record stated the strip steps around "a sys_-prefixed or managedBy
object". Prose inside string literals; no assertion reads their content.
⛔ What was NOT changed
the other way.
stripReadonlyFields's two update call sites are untouched.beforeInsert's post-hook strip position — settled by ruling C on finding: the insert-pathreadonlystrip is a protocol-boundary guard only —engine.insertapplies none of it, andcreate_record'sonFieldsDroppedchannel can never fire for a readonly drop #14147.managedBy's enum, its.describe(), and every other authorable key.Evidence
Red before / green after, in the real direction. The unit file was written and
committed first, then run against the unmodified predicate:
53dbf78738(test only, fix not yet written):Test Files 1 failed (1) · Tests 5 failed | 8 passed (13), each failure readingAssertionError: platform holds the user's own data — the strip applies as it does on update: expected null not to be null. The 8 that passed are the unchanged half —the three platform-internal buckets, the
sys_cases, thesys_metadata_historypinand the enum partition.
Test Files 3 passed (3) · Tests 219 passed (219)over the new file plusengine-insert-static-readonly-strip.test.tsandrule-validator.test.ts.Package suites (
pnpm --workspace-concurrency=2 --filter @objectstack/objectql --filter @objectstack/lint test):packages/objectql:Test Files 293 passed (293) · Tests 4909 passed (4909)packages/lint:Test Files 103 passed (103) · Tests 3717 passed (3717)@objectstack/dogfoodtest/authz-conformance.test.ts:Test Files 1 passed (1) · Tests 47 passed (47)Typecheck — both packages green, including their test layers
(
check:test-typecheck: OKfor each; objectql's ledger unchanged at 44 files / 242errors / 69 pinned signatures, lint's at 2 / 6 / 2).
Gate families — derived with
scripts/pm/dispatch-gates.mjs --commandsfrom thishead's own change set and reconciled with
--ran:70 derived famil(ies) accounted for — 68 run, 2 NOT-MEASURED.The two are
pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt, bothexit 3 = PREREQUISITE NOT MET: they read a fully built workspace, which is CI's run,
not a per-PR one.
pnpm check:lean-entry-closurealso refused at first for the sameclass of reason and was re-run green after building
@objectstack/objectql(
2 published condition(s) measured from a real load).Repo-wide
pnpm lint(eslint . --no-inline-config, the whole population, not anarrowing): exit 0 at
c8092ff585, the final commit.Control characters:
pnpm check:nul-bytesgreen, plus a directgrep -naPsweep over all ten changed paths with a positive control that fired.The Clause-② declaration, and how it was settled
The dispatch claim declared
Clause-②: no. The maintainer ruling comment(
5550128462) declaredyesin its Execution paragraph, four lines below the headlinethe claim quoted. This PR originally carried the claim's declaration and flagged the
disagreement rather than picking a side quietly; the dispatching seat then adjudicated
it and ruled B —
Clause-②: yesstands, withminor+ the**BREAKING**banner +an adr-0087 disposition, exactly as the ruling's Execution paragraph prescribed
(#15719 (comment)).
The line at the top of this body carries that verdict. Nothing about the change itself
moved — this was a declaration-and-grade correction, applied as a new commit on top of
the reviewed head, with no rebase, amend or force-push.
Generated by Claude Code