Skip to content

fix(objectql): the create-side static-readonly strip judges the user-writable managedBy buckets (#15719) - #17340

Merged
os-sam merged 7 commits into
mainfrom
claude/issue-15719-readonly-create-update-symmetry
Sep 10, 2026
Merged

os-sam merged 7 commits into
mainfrom
claude/issue-15719-readonly-create-update-symmetry

Conversation

@claude

@claude claude Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #15719

Clause-②: yes

Per the maintainer ruling's Execution paragraph (5550128462), verbatim:
「Clause-② yes (create accept set moves on the affected objects); @objectstack/objectql
minor with the launch-window **BREAKING** banner and an adr-0087 disposition.
」
The ruling prescribes the declaration, the grade and the banner together, so the minor
here 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 const in rule-validator.ts, deliberately not exported
(which is also why @objectstack/lint narrows a mirror rather than importing one).

What was asymmetric

staticReadonlyInsertSubject (packages/objectql/src/validation/rule-validator.ts)
returned null for managedBy set to anything, so the CREATE path judged no
static readonly column on such an object, while the UPDATE path passed the raw
schema to stripReadonlyFields and stripped it. For a managedBy object a
non-system caller's create of a static readonly column was not stripped while the
same 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.ts says in the
same 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:

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 as system-data get the same static-readonly strip on
create that they already [have on update]

Census (a) — every managedBy bucket, classified

The classification axis is object.zod.ts's own enum docblock, "Enforcement happens in
three places", item 3: which buckets have an engine write guard that fails closed on a
user-context generic write.

bucket its own refusal create-side strip
engine-owned ADR-0103 engine-owned write guard steps around it
append-only ADR-0103, same guard (locked default) steps around it
better-auth ADR-0092 identity write guard steps around it
platform none — full user CRUD by default judges it
config none — admin-authored, writable by default judges it
system-data none — "admin/user-writable DATA" judges it

That 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 fails
there instead of landing silently on the judging side.

Census (b) — the shipped population, measured on this head

Every .ts object declaration in packages/, apps/ and examples/ carrying a
literal managedBy, parsed with the TypeScript AST (field definitions resolved through
the Field.*(...) builders, options object read from whichever argument carries it):

reading value
object declarations carrying managedBy 81
of those, named outside the sys_ namespace 0
in the three now-judged buckets, carrying a static readonly column 20 objects (platform 6, config 6, system-data 8)
static readonly columns on those (runtime-owned types excluded) 64
of those 64: ADR-0086 package-provenance family 14 — package_id, managed_by, customized, drift_status, drift_detail, is_system, all on config objects
of those 64: id / created_at / updated_at stamps 50
in the three still-excluded buckets 60 objects, 307 static readonly columns

The 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_report
  • config (6 objects, 29 columns): sys_capability, sys_email_template,
    sys_permission_set, sys_position, sys_sharing_rule, sys_webhook
  • system-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_preference

Behaviour delta on the shipped population: zero. Because no shipped managedBy
object is named outside sys_ — every one of the 20 above included — the namespace
test, 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.

⚠️ Both halves or neither. "Behaviour-free" alone overstates it: the population is
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_at stamps that guard does not
reach. So the narrowing is the route, not option 1.

sys_metadata_history.recorded_by — seeded by a direct, non-system engine.insert
from sys-metadata-repository.ts — is doubly exempt (engine-owned bucket and the
sys_ namespace) and is pinned as unaffected against the real exported object, not a
replica.

What changed

  • packages/objectql/src/validation/rule-validator.ts — the predicate now tests the
    bucket against a module-local PLATFORM_INTERNAL_MANAGED_BUCKETS set instead of
    testing managedBy for truthiness. An unrecognised value is deliberately not
    read 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, which
    said "platform objects to their own 403 guards". Prose only; the strip's position
    after the beforeInsert hooks is untouched.
  • packages/objectql/src/readonly-strict-errors.ts — the same sentence, which already
    cited this card.
  • packages/lint/src/validate-readonly-flow-writes.ts — buildInsertStripExemptObjects
    is 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 the
    authz 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

Evidence

Red before / green after, in the real direction. The unit file was written and
committed first, then run against the unmodified predicate:

  • red, at 53dbf78738 (test only, fix not yet written):
    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 that passed are the unchanged half —
    the three platform-internal buckets, the sys_ cases, the sys_metadata_history pin
    and the enum partition.
  • green, after the fix:
    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.

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/dogfood test/authz-conformance.test.ts: Test Files 1 passed (1) · Tests 47 passed (47)

Typecheck — both packages green, including their test layers
(check:test-typecheck: OK for each; objectql's ledger unchanged at 44 files / 242
errors / 69 pinned signatures, lint's at 2 / 6 / 2).

Gate families — derived with scripts/pm/dispatch-gates.mjs --commands from this
head'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-loads and pnpm check:type-check-debt, both
exit 3 = PREREQUISITE NOT MET: they read a fully built workspace, which is CI's run,
not a per-PR one. pnpm check:lean-entry-closure also refused at first for the same
class 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 a
narrowing): 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.

The Clause-② declaration, and how it was settled

The dispatch claim declared Clause-②: no. The maintainer ruling comment
(5550128462) declared yes in its Execution paragraph, four lines below the headline
the 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-②: yes stands, with minor + 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

…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
…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
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/readonly-strict-errors.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 21 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 706ad0fcc4ed4f76da4e22255bd4fb0f2edae2bc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c051f9224cbb9c3692aa368041b99cc4d87d52e0 — the merge of head c82b118ba9c53cc7429dd48f5fbeaabfabc9ed67 into base 706ad0fcc4ed4f76da4e22255bd4fb0f2edae2bc, 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 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

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

…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
Merged via the queue into main with commit 5b5bd36 Sep 10, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[decision] readonly on create vs update is asymmetric on every managedBy object — including the admin/user-writable system-data bucket

1 participant