Skip to content

feat(platform-objects,plugin-auth): make sys_user.locale user-writable, with a loud BCP-47 refusal (#14787) - #14958

Merged
hotlong merged 7 commits into
mainfrom
claude/issue-14787-user-writable-locale
Sep 3, 2026
Merged

feat(platform-objects,plugin-auth): make sys_user.locale user-writable, with a loud BCP-47 refusal (#14787)#14958
hotlong merged 7 commits into
mainfrom
claude/issue-14787-user-writable-locale

Conversation

@claude

@claude claude Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #14787

Implements maintainer ruling B on the decision card (comment 5522882528, adopted verbatim 「同意」): the identity table's user-writable set grows from two fields to three.

The ruling, and the three edits that carry it

Ruled: B. The identity table's user-writable set grows from two fields to three — a security-boundary act taken by the maintainer and recorded as such. SYS_USER_PROFILE_EDIT_FIELDS becomes {name, image, locale}; MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user gains locale; the column's readonly goes; the identity-write-guard and managed-extension-fields pins are flipped rather than deleted. A malformed value is refused loudly by the column's BCP-47 shape check (LOCALE_TAG_SHAPE) and never dead-letters a notification; the deployment default remains the fallback for an unset column. Option A (system-context writes only) is not taken — it would make every application build its own stamping route.

The three edits are inert one at a time, which is why they are one PR:

edit file without it
SYS_USER_PROFILE_EDIT_FIELDS = {name, image, locale} plugin-auth/src/sys-user-writable-fields.ts the identity write guard strips locale, and a locale-only PATCH throws
MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user = {locale} plugin-auth/src/managed-extension-fields.ts the declaration and the enforcement disagree about the column
readonly removed from the column platform-objects/src/identity/sys-user.object.ts stripReadonlyFields deletes the caller's value on the update path before the guard or the validator sees it — the whitelist entry alone is a silent no-op

SYS_USER_IMPORT_UPDATE_FIELDS inherits the widening by construction (it is a spread of the profile set, not a second list), which ADR-0092 D3 is explicit about.

The prerequisite, re-measured rather than assumed

The ruling sequenced this after PR #14775 (#13881) had merged, and the card's triage note recorded git grep 13881 -- packages/ at 0 — a measurement that predates the merge. Re-measured on current origin/main: sys_user.locale is present in packages/platform-objects/src/identity/sys-user.object.ts with readonly: true, maxLength: 35, Profile group, and MANAGED_EXTENSION_FIELDS.sys_user already declares it. The premise holds; the stale note does not.

The safety property was NOT already enforced — this PR adds it

LOCALE_TAG_SHAPE (the constant the ruling names) lives in service-messaging/src/recipient-locale.ts and is a read-side normalizer: it silently drops a malformed value at delivery time and falls back. Nothing on the write path checked the shape at all — readonly was a strip, maxLength bounds length, and the field schema's format key is authoring metadata that no write path reads. So "a malformed value is refused loudly" was a property no code held on the day it was ruled.

It is enforced here by an object-level format validation rule on the column:

validations: [{
  type: 'format', name: 'locale_bcp47_shape', field: 'locale',
  regex: SYS_USER_LOCALE_TAG_PATTERN, severity: 'error',
  message: 'Locale must be a BCP-47 language tag, such as zh-CN or ja-JP.',
}]

objectql's rule validator runs validations on insert, by-id update and bulk update, so there is no write shape that reaches the column without passing it. A violation returns the standard VALIDATION_FAILED envelope with a per-field { field: "locale", code: "invalid_format" } entry, mapped to HTTP 400 by mapDataError. The message is translated in all four shipped locales, so check:i18n-coverage stays at 0 untranslated for platform-objects.

Two things the check deliberately does not do, both pinned so a later reader does not "fix" them:

What did not widen, and why that is the answer rather than a deferral

ADR-0092 D6's session-snapshot mirror keeps {name, image}. The old code derived it from the update whitelist, which was correct only while the two coincided; its own comment said "widening the whitelist must extend this mapping deliberately". Deliberately, then: better-auth carries no locale on its user model and it is intentionally not an additionalFields entry (declaring it there would make getSession SELECT a column an environment that has not run schema-sync does not have). There is no cached copy to keep coherent, and merging one in would manufacture an incoherence — a user.locale key present only on cached sessions, only after a profile edit. The mirror set is now a named constant beside the whitelist rather than derived from it.

⚠️ Measured gap: the ruling opened the COLUMN, not a self-service route

Reported rather than acted on, because closing it is a second security-boundary decision the ruling did not take. After these three edits:

  • the guard admits locale on a user-context update to sys_user;
  • but member_default still declares allowEdit: false on sys_user (plugin-security/src/objects/default-permission-sets.ts, via denyWritesOnManagedObjects()), so an ordinary member's PATCH to their own row is refused by the permission layer before the guard is consulted;
  • and better-auth's /update-user — what the existing update_my_profile action posts to — cannot carry the field, since locale is not a better-auth additionalFields entry.

So today a platform admin can set any user's locale through the standard edit form, and a rank-and-file member cannot set their own. ADR-0092 D5 is explicit that this split is intended ("the guard decides which columns any permitted actor may touch"; permission sets decide who), so this PR is faithful to the ruling as written. Granting the member route would follow the sys_api_key shape — an explicit member_default entry plus a _self row scope for writes — and would also open name/image self-editing through the data path, which D5 currently routes through better-auth. That is a decision, not an implementation detail.

ADR-0092 is not edited here — deliberately

ADR-0092 D1's tier table records Tier 1 as {name, image}, and this PR makes the shipped set {name, image, locale}. Under Prime Directive #13 that reversal wants an amended ADR — and under Prime Directive #14 docs/adr/** is a governed surface, so including it here would fork the whole PR into governance territory rather than contract review. Filed separately as #14951 so the ADR amendment lands as its own governed PR. Flagging it here rather than leaving it for the next reader to discover: ADR-0092's own scope note anticipates whitelist registrations as ordinary work "citing this ADR", but the sys_user tier table is D1's decision text, not an example.

The composing pair — this is the WRITE, #14788 is the READ

#14788's endpoint precedence (sys_user.locale when set → Accept-Language → deployment default, ruled D in the same batch) is the reading side of this write. The two were ruled together on purpose: a value the user chose is stronger evidence of intent than the Accept-Language the browser just sent, so the column has to outrank the header — which is only meaningful once the user can actually set the column. Neither half is worth much alone.

Clause-② — re-derived from this diff, and it agrees with the ruling

yes. A published write path accepts a field it refused before: a user-context PATCH /api/v1/data/sys_user/:id carrying locale was answered with 403 PERMISSION_DENIED (or had the key stripped) on main and now commits. The needs:contract-review carrier is hung on the card and on this PR by the dispatching seat. My measurement and the ruling's agree; the PR stays a draft and this seat does not undraft it or arm auto-merge on CI colour.

Pins

The flipped pins are reversals with the reversal named, never deletions:

  • identity-write-guard.test.tsgetManagedUpdateWhitelist('sys_user') now pins {name, image, locale}; the error text pin moves to Editable fields: name, image, locale.
  • managed-extension-fields.test.tslocale moves out of the "declared but NOT generically editable" list into its own positive pin, with the sentence it used to carry ("stays off the editable map until a ruling widens the ADR-0092 D2 profile whitelist") quoted as the thing that came true.

New pins, in the three directions the deliverable asks for:

  • a user CAN set locale — it survives the guard untouched;
  • a user still CANNOT set anything else — 14 columns, one per ADR-0092 tier-2/tier-3 family, each refused alone AND stripped when smuggled beside a legal locale. This is the pin that catches a widening that widened too far, and it is asserted on the whole set literal rather than by has() probes, because a membership probe cannot see a fourth name arrive;
  • a malformed tag is refused, not stored — driven through the real evaluator against the real SysUser schema, asserting the envelope (code, name, per-field code) rather than that something threw;
  • the D6 mirror does not carry locale — including when a locale change rides along with a mirrored one;
  • the map and the registered whitelist agree for sys_userauth-plugin.ts skips that object in the registration loop, so nothing else holds those two lists together;
  • read side and write side share one BCP-47 spellingrecipient-locale-shape-parity.test.ts asserts byte equality plus behavioural agreement over a corpus, and pins the one intended asymmetry ("null" is shape-legal on both sides; only the read side's stringified-nothing list refuses it).

Verification

All figures from git rev-parse --short HEAD = 631038b03.

  • pnpm --filter @objectstack/plugin-auth exec vitest run src/identity-write-guard.test.ts src/managed-extension-fields.test.ts src/sys-user-locale-write-contract.test.ts — 3 files, 70 passed
  • pnpm --filter @objectstack/service-messaging test — 33 files, 364 passed (includes the new parity pin)
  • pnpm --filter @objectstack/platform-objects test — 33 files, 518 passed
  • pnpm --filter @objectstack/plugin-auth --filter @objectstack/platform-objects --filter @objectstack/service-messaging typecheck — all three Done; plugin-auth's test layer also passes check:test-typecheck against its shrink-only ledger (10 files / 94 errors / 23 pinned signatures held, unchanged by this diff)
  • gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after every commit that changed the file set (the changeset and the translation bundles each added families), and the union re-run on the final head. All exit codes captured by redirect-then-read, never across a pipe.
  • one gate went genuinely red on this diff and was repaired: check:i18nplatform-objects: 7 bundle(s) drifted from the schema, because the new rule's message is a translatable leaf (objects.sys_user._validations.locale_bcp47_shape.message). Regenerated with node scripts/check-i18n-bundles.mjs --write, then the three non-en leaves were hand-translated and their fill-provenance entries dropped from the source-hash bundles, which is what keeps the coverage ratchet at zero rather than at one. Now check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys) and check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
  • NOT MEASURED, recorded as neither green nor red (exit 3, quoting each gate's own verdict): check-test-completeness"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named […] the local reading for this gate is NOT MEASURED". check:i18n, check:i18n-coverage and check:dual-build-cjs-loads each reported exit 3 first for a missing build prerequisite; all three had that prerequisite cleared and were then run for real, so they are measured rather than left at 3. check:dual-build-cjs-loads then went red once on the final head with four findings against @objectstack/metadata ("the require condition declares types ./dist/index.d.cts, which is NOT emitted though packages/metadata/dist exists") — a package this diff does not touch, whose dist/ in this container held a declaration-less build. pnpm --filter @objectstack/metadata build emitted 8/8 declarations and the gate returned to OK, so that reading was local build state and not a finding about this change. Recorded rather than quietly dropped, because a red that is explained away deserves the evidence that explains it.
  • check-system-context-census reddened on pure line rot from this diff's own insertions and was repaired with pnpm check:system-context-census --fix (two anchors: identity-write-guard.ts:98 to :99, auth-plugin.ts:1405 to :1412). No prose was touched; against origin/main this page's diff is those two line numbers and nothing else.

Ablation

Direction predicted before running: RED in two packages, with the well-formed cases staying green.

Mutation: SYS_USER_LOCALE_TAG_PATTERN replaced with a pattern that accepts everything, carrying a unique marker. Both suites resolve @objectstack/platform-objects through its exports to dist/, so every leg rebuilds and proves the bytes landed.

  • mutation on disk: marker count 1, original pattern count 0, worktree blob moved 775b0aae to 3835c294;
  • mutation in dist/: ablation-dist-preflight — marker present in 6 built files;
  • mutated: plugin-auth 10 failed / 14 passed; service-messaging 10 failed / 6 passed. As predicted, and the well-formed and empty-value cases stayed green — a mutation that reddened everything would not discriminate;
  • restore: git checkout HEAD -- on the absolute path, blob back to 775b0aae (equal to the HEAD blob), git diff HEAD 0 bytes, marker count 0;
  • restore in dist/: ablation-dist-preflight --absent — marker absent from all 66 built files, working tree clean against HEAD;
  • restored: plugin-auth 24/24, service-messaging 16/16.

Follow-ups filed

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/platform-objects/src/identity/index.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/platform-objects/src/identity/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 30 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 13 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 d5cbb44f36231d413b4e1c18a7b9bae641cc393cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 9e42c6e1a63c3f61f2b3b389df7825ae9b2bd709 — the merge of head 9288c301dde70d9654c6d52a939bdbfa1023020c into base d5cbb44f36231d413b4e1c18a7b9bae641cc393c, 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 9e42c6e1a63c3f61f2b3b389df7825ae9b2bd709 && git checkout 9e42c6e1a63c3f61f2b3b389df7825ae9b2bd709
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d5cbb44f36231d413b4e1c18a7b9bae641cc393c 9288c301dde70d9654c6d52a939bdbfa1023020c && git checkout -B drift-repro d5cbb44f36231d413b4e1c18a7b9bae641cc393c && git merge --no-ff 9288c301dde70d9654c6d52a939bdbfa1023020c

node scripts/docs-audit/affected-docs.mjs --json d5cbb44f36231d413b4e1c18a7b9bae641cc393c

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

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Docs Drift Check disposed — the bot's empty list is not clearance here, and a hand check found one sentence worth your judgement

domain:services execution seat, reviewer of record. Scoping this precisely: I grepped content/docs on origin/main for the rule this PR moves, read the two hits, and report only on those. I did not survey the corpus.

Why I hand-checked at all

The bot listed no pages, but it declared two blind spots that both apply squarely to this diff:

  • packages/platform-objects/src/identity/index.ts yielded no anchor, and it says so itself: "pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files."
  • sys_user matched 30 pages, "too much of the corpus to be a work list."

Plus its standing caveat that a page stating a rule by its inputs shares no identifier with the emitter and cannot appear on any run. This PR changes a permission boundary, which is exactly the kind of rule prose restates without naming a symbol.

Hit 1 — content/docs/permissions/authentication.mdx:975not yours

mode: 'insert' | 'upsert' … Upsert updates only touch profile fields (name, image, phone_number, role)

Read in context, this is the CSV/Excel bulk import path — a platform-admin feature, a different actor and a different mechanism from the ADR-0092 D2 self-service whitelist. Its field list is already a different set from {name, image}. ⛔ Not falsified by this PR, and ⛔ do not touch it.

(Whether an import should also be able to set locale is a real question and out of scope — ⛔ do not answer it in this PR. Mention it in your report if you think it deserves a card and I will file one.)

Hit 2 — content/docs/permissions/authentication.mdx:195your call, and I want your reading, not my guess

These objects are managedBy: 'better-auth' … generic user-context CRUD through the /data API is suppressed — a fail-closed identity write guard (ADR-0092/0103) rejects direct writes … Mutate these records through the sign-in, invitation, and security flows above, never by writing the objects directly.

Your own Clause-② note says a user-context PATCH /api/v1/data/sys_user/:id carrying locale was 403 PERMISSION_DENIED (or stripped) on main and now commits. That is precisely the /data path this paragraph calls suppressed.

What I could not determine from outside your diff, and where I stop rather than assert: whether name / image already commit through that same /data path today. If they do, this sentence already carried an unstated exception and your change widens it by one field rather than newly falsifying it. If they do not — if the existing whitelist is enforced somewhere other than /data — then your PR is the first to open that door and the sentence goes from imprecise to wrong.

⇒ You hold the measurement I lack. Please state which it is in your report, and:

  • if it is the widening case: judge whether one qualifying clause is owed here. The paragraph describes the very guard your diff edits, so a correction is arguably in scope rather than a rider — but it is a hand-written page and I would rather you justify the edit than make it reflexively.
  • if it is the newly-wrong case: the edit is owed, and say so plainly.
  • ⛔ Either way, do not touch content/docs/releases/**, and repair system-context.mdx anchors only with the census gate's own --fix (for line rot) or the os-regen driver's printed recipe (for a conflict) — see the correction I sent you earlier.

⚠️ Note I am naming the limit of my own check: I searched for the whitelist and for sys_user + edit/profile/writable/readonly/locale. A page that states this rule in still other words would not have surfaced, and the bot could not have surfaced it either.


Generated by Claude Code

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Seat ACCEPT of the delivery — ⛔ still gated on the tier contract review, not landable on this comment

domain:services execution seat, reviewer of record. Head 631038b03.

The single most valuable thing in this round

The dispatch order asked you to confirm that LOCALE_TAG_SHAPE actually fires on the write path you open, on the grounds that "a ruling's stated safety property that no code enforces is the thing worth catching here." You caught it:

LOCALE_TAG_SHAPE lives on the READ side in service-messaging and silently falls back, so nothing on the write path checked the shape at all

The ruling asserts, as a settled property, that "a malformed value is refused loudly by the column's BCP-47 shape check … and never dead-letters a notification." That was not true of any write path before this PR. You added the locale_bcp47_shape rule across insert, by-id update and bulk update, and held the read side byte-identical with a cross-package parity pin.

⇒ A ruling that describes a guarantee is not evidence the guarantee exists. Finding that before shipping is worth more than the feature.

The readonly interaction, which would have shipped a silent no-op

Also worth recording: the three edits are inert one at a time, and specifically without dropping the column's readonly, stripReadonlyFields deletes the caller's value before either the guard or the validator sees it. The whitelist entry alone would have looked correct and done nothing — the exact shape that passes review and fails in production.

The D6 session mirror was reasoned, not copied

Declining to widen the ADR-0092 D6 session-snapshot mirror alongside the whitelist is right, and for the stated reason: better-auth carries no locale on its user model, so mirroring would manufacture an incoherence rather than repair one. Turning the mirror into a named constant instead of deriving it from the whitelist is the correct shape for a set that deliberately diverges.

My census correction — you answered it properly, and it did not apply

I sent you a correction warning that --fix is the line-rot instrument and wrong for a conflict on system-context.mdx. Your answer: both merges of origin/main were clean on that file, so --fix was acting on this diff's own insertions and the merge-driver recipe was never the applicable path.

You checked for dropped prose anyway, as asked, and reported the page carries main's newer prose verbatim with this branch's only delta being two line numbers. That is the right response to a precautionary correction — verify rather than dismiss, and say which case actually held. My correction was not wrong to send; it simply did not bind here.

Verified independently

Clause-② yes, and the carrier pair now reads clean — check-clause2-carriers --pair 14958 exits 0 after I hung the PR-side carrier (it was a real half-state: I pre-hung the card at dispatch, the PR did not exist yet, and the hang never followed). Your independent re-derivation agrees with the ruling and with the mechanical bar — three sources.

The ablation is the shape I want to see repeated: direction predicted before running (red in two packages, well-formed cases staying green — a mutation that reddens everything does not discriminate), every leg rebuilt because both suites resolve @objectstack/platform-objects through its exports to dist/, the marker proven present in 6 built files and absent from all 66 after restore, and the restore proved by blob equality plus an empty git diff HEAD.

The measured gap is now a decision card

Your open_questions entry is the round's second real finding: the ruling opened the column, not the route. member_default still declares allowEdit: false on sys_user, and better-auth /update-user cannot carry the field — so an admin can set any user's locale and a member cannot set their own, and objectui#7501's form item has nowhere to post.

You were right to escalate rather than take option A: it would also open name/image self-editing through the data path, which ADR-0092 D5 currently routes through better-auth, and that is an ADR amendment, not an implementation detail.

Filed as #14959 with your four options and the four-facet analysis, recommending A on the reasoning you gave. ⛔ This PR is not blocked on it — it is faithful to the ruling as written, it is the prerequisite for every live option, and #14788's read-side precedence needs the column writable. The PR body's own "Measured gap" section keeps the record honest about what did and did not ship.

Status

⛔ Stays draft, carriers hung on both, not armed. Next step is the tier contract review; this seat does not self-certify at a lower tier.


Generated by Claude Code

…oved

The new `locale_bcp47_shape` validation-rule name is a snake_case `name:`
literal inside a `*.object.ts` file, which `declaredObjects()` counts, so the
corpus-scale figure moved 297 -> 298 (and sources scanned 540 -> 542 for the
two new test files). Regenerated with the one mechanical repair path,
`node scripts/tenant-audit-census.mjs --write`, and updated the hand-written
prose figure outside the generated region so it still cites the table it points
at. The gate and its self-test are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Blocker on this PR, with the diagnosis — recorded here because the agent holding it keeps being killed by transients

domain:services execution seat. Posting the seat's one standing-down comment per the drive-to-green rule, and putting the diagnosis somewhere durable rather than leaving it in an agent's context.

What is failing

Lint & Repo Gatesnode scripts/check-tenant-audit-census.mjs --self-test, on head 631038b03 (run 33758138801, job 100657466481):

✗ an unenforced prose claim reworded off the page IS a finding
✗ check-tenant-audit-census self-test: 1 of 19 case(s) failed.

It is this PR's — established, not assumed

Lint & Repo Gates conclusions on main either side of this head: 6d178a408success, e560b4d51success. Not a base-branch failure, so ⛔ no re-run is warranted and "flake" is not a candidate.

⚠️ Recorded because it nearly misled me: my first attempt to reproduce on a clean origin/main worktree returned exit 1, which reads exactly like "red on main too". It was ERR_MODULE_NOT_FOUND: Cannot find package 'typescript' — the scratch tree had no node_modules. An environment failure wearing a verdict's exit code. Read the verdict text, never the bare exit status.

The mechanism, read from the gate source at origin/main

t('an unenforced prose claim reworded off the page IS a finding',
  check(page.replace(`Across ${census.declaredObjects} declared objects`, 'Across the declared objects'))
    .some((p) => p.startsWith('[unenforced-prose-missing]')));

The case takes the real page, rewords that exact sentence, and asserts the check then reports [unenforced-prose-missing].

It fails if and only if the .replace() is a no-op — the page does not contain the literal `Across ${census.declaredObjects} declared objects`. Substring absent ⇒ replace returns the page unchanged ⇒ nothing reported missing ⇒ .some(...) is false ⇒ case failed.

And that is why only --self-test reddened while the census itself stayed green. The sibling case immediately above is ⭐ a stale unenforced prose number is NOT a finding — the census treats the value as free by design. But the self-test interpolates the live census.declaredObjects into the literal it searches for, so the instant that count and the page's prose disagree, the self-test breaks and the census does not. That asymmetry is deliberate, and it is the trap this diff fell into.

What is NOT yet determined

Why the literal stopped matching. Two candidates, and ⛔ neither is being assumed:

  1. census.declaredObjects moved because this diff added something the census counts, and the page's prose still carries the old number;
  2. the sentence is no longer in the page in that exact form.

Settling it is a two-sided print: the live census.declaredObjects, and the sentence as it actually appears in the page, shown to disagree.

If it is (1) the fix is the prose number on the census page, not the gate. ⛔ Weakening the case, the self-test, or the interpolation is out of bounds; if the measurement shows the gate is at fault, that is another lane's call and this seat will route it.

Why this is stalled rather than fixed

The dev agent has been terminated by API 529 Overloaded four times, three of them on this task, twice before emitting any output. Nothing is wrong with the worktree or the branch — head 631038b03 stands, tree clean, PR draft. The next attempt is scheduled; nothing here needs redoing.

⚠️ The reason this comment exists at all: that diagnosis was reached, then lost to a transient, then reached again. A finding that lives only in an agent's context is one API error from gone. The PR is the durable record, so it belongs here.

Unaffected

Everything else about this delivery is accepted (see the ACCEPT above): the BCP-47 write-path catch, the readonly interaction, the D6 mirror reasoning, and the ablation. The measured gap is decision card #14959. needs:contract-review remains hung on both carriers — the tier review has not run.


Generated by Claude Code

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Contract review: PASS at head 631038b03 — landing held on this PR's own CI red; carriers stay hung until green

Director seat, summon #12, session session_01WXyGTWPbbrebqXow7Z2pZCk.replace — session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER. This is the tier review the services seat's ACCEPT (5526213625) was waiting for.

① Derived judgments — against ruling 5522882528 (maintainer 「同意」, option B)

  • Accept set widens on one published write path: a user-context update to sys_user carrying locale was refused (403) or stripped; it now commits. The three coupled edits are all present and each is necessary: SYS_USER_PROFILE_EDIT_FIELDS = {name, image, locale}, MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user = {locale} (pinned as a subset of the registered whitelist — auth-plugin.ts skips sys_user in the loop, so nothing else holds the two lists together), and readonly removed from the column (without it stripReadonlyFields deletes the value before the guard sees it). SYS_USER_IMPORT_UPDATE_FIELDS inherits by construction.
  • The ruling's safety property is now enforced rather than assumed. No write path checked the BCP-47 shape before this PR (LOCALE_TAG_SHAPE is a read-side normaliser that silently falls back). The column now carries the object-level format rule locale_bcp47_shape — evaluated on insert, by-id update and bulk update, VALIDATION_FAILED / invalid_format / 400 — with absent, null and '' passing so clearing returns the user to the deployment default, exactly the fallback the ruling preserves. Shape, not membership — correct for the same reason [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881's per-recipient ladder has a floor.
  • D6 session-snapshot mirror stays {name, image} — and that is right, not deferred: better-auth carries no locale on its user model and it is deliberately not an additionalFields entry, so a merged key would exist only on cached sessions. The mirror is now a named set beside the whitelist instead of derived from it; the guard tests both sets (whitelist: did the write land; mirror: does better-auth cache it).
  • Public surface: SYS_USER_LOCALE_TAG_PATTERN exported from @objectstack/platform-objects; LOCALE_TAG_SHAPE now exported from @objectstack/service-messaging; byte-identity pinned cross-package (recipient-locale-shape-parity.test.ts).
  • Who may write is unchangedmember_default still denies allowEdit on sys_user, so an admin can set any user's locale and a member cannot set their own. ADR-0092 D5 says that split is intended; the ruling opened the column, not a route. Correctly reported, not acted on: [Decision] May a rank-and-file member set their OWN sys_user.locale? Ruling B opened the COLUMN; ADR-0092 D5 still answers no to the ROUTE, so today only admins can set it #14959 (needs-user-decision) carries it.

② Semver vs changeset

@objectstack/platform-objects minor ✓, @objectstack/plugin-auth minor ✓. Advisory, not blocking: @objectstack/service-messaging is declared patch but gains a new exported symbol (LOCALE_TAG_SHAPE); by the same mechanical floor that makes a new export Clause-② yes, the level is minor. Fold that one-line bump into the fix commit below.

③ Boundary flags

ADR-0092 D1 tier table still records {name, image}#14951 (governed surface, separate PR) ✓. objectui#7501 filed with Blocked-by: ✓. The import-mode locale question (authentication.mdx:975) is out of scope and stays a question, not an edit ✓. system-context.mdx: two anchors only, --fix on this diff's own insertions; both main merges were clean on the file ✓.

What holds landing, and what does not reopen this PASS

Lint & Repo Gates is red on check-tenant-audit-census --self-test (1 of 19) — this PR's own, diagnosed by the services seat (5526667067): the census page's prose no longer contains the literal Across N declared objects the self-test interpolates from the live count. That is a prose-number repair on the census page, never a change to the gate.

A fix commit confined to (a) the census page prose and (b) the service-messaging changeset level does not reopen this PASS. Any change under packages/** source or to the three coupled edits above does — re-review required. Once Lint & Repo Gates is green on that head, the services seat strips needs:contract-review from both carriers citing this comment, marks ready and arms auto-merge; the carriers stay hung until then so the gate is not left stripped against a moving head.


Generated by Claude Code

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Correction to the first line of the review comment above (5527214281): the garbled session token there is an editing artifact. The reviewing session is session_01WXyGTWPbbreqXow7Z2pZCk — the director seat, summon #12 — as stated correctly in the same sentence. Nothing else in that comment changes.


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

os-dev-report

{
  "issue": 14787,
  "status": "done",
  "branch": "claude/issue-14787-user-writable-locale",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/14958",
  "premise_still_valid": true,
  "summary": "Narrow takeover: fixed the one red Lint & Repo Gates check, check-tenant-audit-census.mjs --self-test, on PR 14958. Two-sided measurement at head 631038b03 (pre-fix): live census.declaredObjects = 298 (scannedSources = 542), but the hand-written prose sentence outside the generated region at content/docs/permissions/tenant-audit-census.mdx line 87 still read Across 297 declared objects -- stale by one. Root cause is candidate (1) from the diagnosis comment: the new locale_bcp47_shape validation-rule name literal in a *.object.ts file is counted by declaredObjects(), moving the count, while the prose number did not follow. The fix was already present as a local, unpushed commit (cacedbd16) on this same session's branch -- produced in an earlier turn killed by a transient before it could push -- via the documented mechanical repair path (node scripts/tenant-audit-census.mjs --write) plus updating the one hand-written figure. I verified it independently (re-ran the regen; the only residual diff was the informational Measured-on-SHA stamp advancing to the new HEAD, which the page itself states is not gate-compared, so I discarded that churn) and pushed it. Answering the dispatch's question 5: check-tenant-audit-census.mjs IS present in node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands output, invoked correctly as a direct node call (node scripts/check-tenant-audit-census.mjs) -- not mis-invoked as a pnpm run check:* alias, so the sibling dev's defect class does not apply here. A separate, real nuance worth recording against 14880: dispatch-gates.mjs derives exactly one command entry per matched script path, so it collapses CI's two same-script invocations in .github/workflows/lint.yml lines 1565-1566 (one with --self-test, one plain) into a single plain entry. The --self-test invocation -- the one that actually carried this red -- has no separate entry in the derived list. A dev following the derived list verbatim, without independently reading the workflow file, would run only the plain census gate (green even before this fix, per the diagnosis comment) and could miss the failure mode entirely. Scope discipline held: did not touch the delivered implementation, packages/spec, content/docs/releases, any test, or the PR's draft/ready state. Noting for the record rather than acting on it: the director's contract-review comment (5527214281) flagged the service-messaging changeset level (patch, should be minor for the new LOCALE_TAG_SHAPE export) as advisory-not-blocking and asked for it to ride the fix commit -- left untouched since this dispatch scoped the task to the one red gate only.",
  "tests": "Reproduced RED at 631038b03 (detached checkout inside the existing worktree, tree was clean so nothing was at risk; returned to the branch head afterward with zero diff): node scripts/check-tenant-audit-census.mjs --self-test, exit 1, own verdict text 'an unenforced prose claim reworded off the page IS a finding' / 'check-tenant-audit-census self-test: 1 of 19 case(s) failed.' -- exact match to the diagnosis. Confirmed GREEN at head cacedbd16 (the branch's actual head, now pushed): same command, exit 0, verdict 'check-tenant-audit-census self-test: 19 cases pass (...)'. Also ran the plain invocation CI runs second (lint.yml line 1566): node scripts/check-tenant-audit-census.mjs, exit 0, verdict 'check-tenant-audit-census: OK -- 219 write call sites certified (147 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.' Both commands match lint.yml lines 1565-1566 verbatim. Pushed: git push -u origin claude/issue-14787-user-writable-locale, 631038b03..cacedbd16, fast-forward. Re-derived the gate family with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after git fetch origin main (tool flagged the local tree as behind origin/main with two of its own source files changed across that range -- noted, not resolved by merging main since that was out of this narrow task's scope). Ran the 21 direct-node/pnpm gates that dispatch-gates.mjs matches specifically against the two files this fix touched (content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md), each command captured via redirect-then-read (never across a pipe) and read by its own verdict text: node scripts/check-doc-frontmatter.mjs, check-doc-route-spelling.mjs, check-docs-section-name.mjs, check-section-landing-index.mjs, check-cross-package-test-inputs.mjs, check-ci-filter-parity.mjs, check-shard-attestation.mjs, check-nul-bytes.mjs, and pnpm check:corpus-claim-drift / doc-anchors / doc-authoring / docs-audit-scope / docs-redirects / docs-single-h1 / merge-driver / published-readme-links / react-page-adapter-contract / role-word / skill-identifier-liveness / vendor-version-stamps -- all 21 exit 0 and green. One gate, node scripts/check-test-completeness.mjs, returned exit 3, own verdict: 'PREREQUISITE NOT MET -- this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED ... It is NOT a finding' -- recorded as NOT MEASURED, neither green nor red. Post-push, PR 14958 stayed draft (confirmed via pull_request_read) and Lint & Repo Gates is in_progress on the new head per GitHub's own check-runs listing -- reported honestly rather than awaited, per the drive-to-green rule that CI convergence is the PM's to watch.",
  "mcp_calls": "5",
  "open_questions": [],
  "out_of_scope_findings": []
}

Generated by Claude Code

`@objectstack/service-messaging` now exports `LOCALE_TAG_SHAPE`, a new public
symbol. Per the director's contract review (631038b, comment 5527214281):
"by the same mechanical floor that makes a new export Clause-2 yes, the level
is minor." Declared `patch`; corrected to `minor`. No other change -- this is
exactly the "(b) the service-messaging changeset level" half of the envelope
the same review authorized alongside the census prose fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

os-dev-report

{
  "issue": 14787,
  "status": "done",
  "branch": "claude/issue-14787-user-writable-locale",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/14958",
  "premise_still_valid": true,
  "summary": "Follow-up authorized by the coordinator: bumped the service-messaging changeset entry from patch to minor, per the director's contract-review advisory (5527214281) -- LOCALE_TAG_SHAPE is a new exported symbol, and the same mechanical floor that makes Clause-2 yes for a new export makes the changeset level minor. Touched exactly one line -- the frontmatter value in .changeset/sys-user-locale-user-writable.md -- nothing under packages/**, none of the three coupled edits, no test, no census page. Committed separately (9288c301d, on top of cacedbd16) so the census-prose commit stays a clean standalone unit. Re-derived the gate family after the commit and ran every changeset-reading gate it names: check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, check:changeset-gate-self-tests, check:objectui-changeset (check:changeset-fixed is not a real derived command -- confirmed absent from the derived list, so it was not run). All five green. Pushed. PR remains draft, needs:contract-review label untouched, draft/ready state left for the coordinator as instructed.",
  "tests": "git diff on the commit is exactly one line: `\"@objectstack/service-messaging\": patch` to `\"@objectstack/service-messaging\": minor`; confirmed via git diff before committing. Re-derived the gate family with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the new head (9288c301d): exit 0. Ran all five changeset-reading gates it names, each captured via redirect-then-read (never across a pipe), verdict text quoted -- node scripts/check-adr-0087-registration.mjs: exit 0, 'check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).'; node scripts/check-changeset-no-major.mjs: exit 0, 'This diff introduces no major bump.'; node scripts/check-empty-changeset.mjs: exit 0, 'No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).'; pnpm check:changeset-gate-self-tests: exit 0, all three self-tests pass (check-empty-changeset --self-test 118 assertions, check-adr-0087-registration --self-test 292 assertions, check-changeset-no-major --self-test 116 assertions); pnpm check:objectui-changeset: exit 0, 'objectui-range --self-test: all checks passed'. check:changeset-fixed confirmed absent from the derived --commands list (grepped, no match), so not a command to run here. Pushed: git push origin claude/issue-14787-user-writable-locale, cacedbd16..9288c301d, fast-forward. Post-push pull_request_read confirms draft: true, head sha 9288c301d, labels unchanged (needs:contract-review still hung).",
  "mcp_calls": "9",
  "open_questions": [],
  "out_of_scope_findings": []
}

Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 15:23
@hotlong
hotlong enabled auto-merge September 3, 2026 15:24

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Landing, director seat (session_01WXyGTWPbbreqXow7Z2pZCk), head 9288c301d.

The PASS in comment 5527214281 was held only on this PR's own CI. Delta between the reviewed head and 9288c301d, read with git diff: the changeset level for @objectstack/service-messaging moves patch to minor (consistent with the new exported spelling the review counted as public surface), and the tenant-audit census page plus its counts file re-measure at 631038b03 (298 declared objects, 542 sources). No source file moves, so the review's judgments stand. CI on 9288c301d: 34 check runs, all completed with conclusion success or skipped, Lint & Repo Gates included.

needs:contract-review stripped from this PR and from card #14787 (read-modify-write, read back); marked ready; auto-merge (squash) armed. Not a governed surface.


Generated by Claude Code

@hotlong
hotlong added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 2fd3f1c Sep 3, 2026
39 checks passed
@hotlong
hotlong deleted the claude/issue-14787-user-writable-locale branch September 3, 2026 15:51
os-steve pushed a commit that referenced this pull request Sep 3, 2026
…cript path

`dispatch-gates` keyed a discovered family on the script PATH, so CI's two
invocations of one script collapsed into a single entry — and the entry kept
was the plain one, because the direct matcher captured the path and dropped the
argument tail. Measured on PR #14958: `lint.yml` runs
`node scripts/check-tenant-audit-census.mjs --self-test` beside the plain run,
the red was carried entirely by the `--self-test` invocation, and the derived
list named only the invocation that was already green. A dev following the list
verbatim could not see the failure mode at all.

The key is now (script, args). The argv half is admitted only when this tool can
render the invocation runnably — a complete run of flag-shaped tokens; a tail
carrying a value, or continued onto the next line, keeps the bare path key it
has today rather than being truncated into a command that cannot run.

Read from this tree's workflow text: 28 scripts in `lint.yml` are invoked more
than once under different argv, 41 across all workflow files, and every one of
them carried a `check-` basename and so collapsed. 204 discovered families
become 242; three `check-` scripts CI never invokes plainly lose a bare key
that named a command nobody runs.

Also: a `--self-test` invocation is never CI-measured-only. The payload-access
read that classification rests on is taken from the gate's work body, which a
self-test run does not execute, so suppressing it from `--commands` would hide
a command a dev can run.

And the usage line printed on the derivation-failure path moves `--residue`
inside the alternation, so it no longer advertises the `--tier --residue` pair
the CLI has refused since #14753.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
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

3 participants