Skip to content

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

Description

@zhuangjianguo

Blocked-by: #14147

Ungraded and unrouteddomain:*, priority and type are triage's. Filed unassigned by the domain:engine execution seat.

⚠️ This card exists because the seat owed it and did not file it. The adoption record on #14147 (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)

CREATEpackages/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;

UPDATEengine.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).

⚠️ What makes it worth a ruling is the bucket list, not the 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:

  1. 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.
  2. Narrow the exclusion to the buckets the ADR-0086 argument actually covers (platform internals and the sys_ namespace), letting the create-side strip apply to system-data and any other user-writable bucket — which would make create and update symmetric on exactly the population where the data is the user's.
  3. 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

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

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions