Skip to content

Commit b53b949

Browse files
fix(runtime)!: the /i18n dispatcher domain refuses an anonymous caller, with the console pin moved past the sign-in companion (#22432) (#22496)
Fixes #22432 Clause-②: no (narrowing) > **Round 2: the pin bump's citation re-measure is in this PR.** The seat widened the claim to it in surface extension `6083306429` on #22432, and the previous bump (PR 22412) set the precedent for doing it in one PR. Each of the 55 `packages/spec` records that asserted the old pin `f0268ad78` is re-measured at `47b1f0bb7174`, and `check:objectui-pin-citations` now exits 0. No record's read point died or changed meaning. The diff now reaches non-test `packages/spec/src/**`, so a contract-tier review is owed before the ACCEPT (by face, `contract-review.md`). ## What changed `packages/runtime/src/domains/i18n.ts#handleI18nRequest` now opens with the shared anonymous-deny decision, `shouldDenyAnonymous` from `@objectstack/core` (ADR-0056 D2). It is the handler's first statement, in the hoisted form the analytics and security domains use: - It runs before the i18n provider is looked up. An anonymous caller gets `401` whether or not a provider is installed, never the `501` an empty slot answers. - It runs before any face reads its parameters. An anonymous request that leaves out its locale gets `401`, never the `400` the face answers. - Every face of the domain answers the dispatcher-wrapper `401 UNAUTHENTICATED` and serves nothing of the bundle. A signed-in caller, an API-key caller and an internal system context are served exactly as before. A CORS preflight is unchanged. - No new error code and no spec key, so `Clause-②: no`. No second gate at the dispatcher mount: `dispatcher-plugin.ts` is untouched. The console pin moves past the objectui companion in the same PR, through the repo's bump procedure, so the refusal never ships on a Console that reads the domain before sign-in. This executes ruling `6074960686` on #22146, item 1, verbatim: > 1. **The `/i18n` dispatcher domain** gains the domain-level anonymous refusal every other dispatcher domain has (ADR-0056 D2; 401), **with an objectui companion**: the Console serves its sign-in page from the built-in packs and loads the application's translations after sign-in. The dev measures first whether the Console re-fetches translations once signed in today, and sizes the companion from that reading; the two land together so no signed-in user sees raw keys. ## Readings (class level: faces, caller classes, statuses and codes) A real boot of this branch's build: `pnpm dev -- --fresh` (the showcase, with `I18nServicePlugin` auto-registered), at `5b97d08ae`. The faces are the locale list, the translation bundle and the field labels. | face | caller | after | |---|---|---| | each of the three faces | anonymous | **401 `UNAUTHENTICATED`**, dispatcher-wrapper envelope, no `data` | | each of the three faces | signed-in admin, bearer | 200, served | | each of the three faces | signed-in admin, cookie | 200, served | | control: the metadata read of a showcase object | anonymous | 401 | The anonymous body is the dispatcher's wrapped envelope, so the gated dispatcher handler is the one answering on this composition. `I18nServicePlugin`'s own mounts of the same paths register later, at `kernel:ready`, and the first registration wins. The "before" column comes from the ablation below: on the booted showcase, with the gate removed, each anonymous face answered 200. ## The Console at the new pin, in a browser (triage's pin) Chromium (`/opt/pw-browsers/chromium`, Playwright 1.63.0) against the same boot, served the bumped Console (`/_console/`, built from objectui `47b1f0bb7174` by `pnpm objectui:build`). Each run signs in through the Console's own sign-in form, then opens the showcase app and its task list. It was run twice, once in `en` and once in `zh-CN`. | run | `/api/v1/i18n` requests signed out | signed in | raw keys on screen | |---|---|---|---| | `en` | **0** | 6, all 200, each carrying the session (bearer and cookie) | none on the sign-in page, after sign-in, the app page or the task list | | `zh-CN` | **0** | 6, all 200, each carrying the session | none on the same four screens | - In `zh-CN` the task list shows the showcase's own translated object, column and status labels after sign-in. The built-in packs carry no showcase labels, so the post-sign-in load is what put them there. - Raw-key scan: every dotted identifier in the visible text whose first segment names a namespace of the Console's built-in pack or the application bundle. Zero hits on all eight screens. Screenshots were reviewed by eye too. - From the same browser with no session: an anonymous read of the domain answered 401 `UNAUTHENTICATED` with no `data`. ## The pin bump (H2) Range `f0268ad78485..47b1f0bb7174`: 14 objectui commits, the companion (objectui#12034, PR objectui#12042) is its tip. Written by the procedure, with no hand edits: 1. `scripts/bump-objectui.sh 47b1f0bb7174 --no-commit`: `.objectui-sha` and `.changeset/console-47b1f0bb7174.md`. The range walked completely: 13 releasing changesets, 0 breaking, so `@objectstack/console` takes `patch`. 2. `pnpm objectui:build`: the Console built at the pin against this tree. The single-zod canary and the "carries THIS tree's spec" check passed. No objectui commit in the range needed a framework change that is not on `main`. 3. `node scripts/gen-sdui-manifest-node.mjs`: `scripts/sdui-manifest.record.json` re-recorded at the pin. `sdui.manifest.json` did not move by a byte (107 components, same sha256). 4. `pnpm gen:sdui-lockstep`, with `OBJECTUI_ROOT` set to the pinned build worktree (its HEAD is the pin): `packages/sdui-parser/objectui-lockstep.json` re-recorded. `check:sdui-lockstep` then read the two parser copies byte-identical over 214 grammar lines and agreed on all 25 codes and the containment predicate. `check:sdui-manifest`, `check:sdui-lockstep` and `check:console-sha` are green at the new pin. The one red is `check:objectui-pin-citations`, the gap at the top. ## The pin citations, re-measured at `47b1f0bb7174` The gate's own procedure, record by record. Each cited objectui file was read at the new pin in a dedicated objectui worktree (the shared checkout's HEAD was not moved), its line numbers re-derived there, and the anchor and the sha moved together. The hop `f0268ad78485..47b1f0bb7174` touches 129 paths over 14 commits and deletes or renames none. Two files a record cites changed on it, each for objectui#11865 (a picker drawn with the shared `Select`): - `ObjectGrid.tsx`, +19/-11: one import line, and the grouped pager's rows-per-page picker redrawn; - `ListView.tsx`, +85/-26: its "Color by field" and rows-per-page pickers redrawn. Every cited line in those two files moved with its text byte-identical. Every other cited file is byte-identical across the hop. | file | records | moved (byte-identical, re-pointed) | byte-identical files only | died or changed meaning | |---|---|---|---|---| | `src/ui/component.zod.ts` | 27 | 4: the `keyboardNavigation` read `ObjectGrid.tsx:5519` → `:5520` (two records); the empty-state message `6529-6530` → `6537-6538` and its draw `6546-6547` → `6554-6555`; `inlineEditable` `1824` → `1825`; the `case 'tree'` arm `ListView.tsx:3853-3872` → `3913-3932`, `:3867` → `:3927` | 23 | 0 | | `src/ui/component.test.ts` | 6 | 0 | 6 | 0 | | `src/kernel/functional-completeness.ts` | 4 | 4: the `case 'calendar'` / `'gantt'` / `'timeline'` / `'map'` arms, each byte-identical and moved by 60; the renderers, `resolveTimelineDateBinding` and `resolveListMapConfig` unmoved | 0 | 0 | | `src/data/api-methods-batch-conformance.test.ts` | 1 | 1: the grid's selection block `ObjectGrid.tsx:4867-4894` → `4868-4895`, its hash-object still `c88443302d40…` | 0 | 0 | | `src/ui/view.zod.ts` | 2 | 0 | 2 (the `FormField.span` record, and its describe, whose sha alone changes) | 0 | | `src/ui/dataset.zod.ts`, `src/ui/action.zod.ts`, `src/ui/action-outcome-messages.test.ts` | 1 each | 0 | 1 each | 0 | | the six `src/migrations/entries/semantic/18.*-unit-in-key.ts` | 6 | n/a: corpus counts | re-counted, see below | 0 | | `src/migrations/registry.ts` | 6 | written by `pnpm --filter @objectstack/spec gen:migration-registry` | | | | `content/docs/references/ui/view.mdx` | | written by `pnpm --filter @objectstack/spec check:generated --fix`, which regenerated only the one artifact it proved stale (`check:docs`) | | | - **The counts the records carry re-read the same:** the `keyboardNavigation` method still finds 15 hit lines against 3 for the control. The kanban counts read 2 / 2 / 11, and objectui still registers no `kanban-ui` block. The `ElementDataSourceGate` shells read 0 / 3 / 3 / 3 / 4. - **The six migration entries:** their corpus counts were re-taken with `git grep -o -F` (the method reproduces every `f0268ad78485` number). - The corpus is now 8281 tracked files. - Every token an entry counts as zero still reads zero: none of them occurs on a line the hop adds or removes. For three entries, a full count of every token at both pins finds no zero that turned non-zero. - The controls moved with the corpus: `objectstack` 17956 → 17980, `@objectstack/spec` 7522 → 7523, `useState` 2622 → 2630, `timeout` 1658 → 1674, `window` 4430 → 4449, `metrics` 404 → 455. `period`, `interval`, `TTL`, `tenant`, `RuntimeConfig`, `resourceLimits`, `Span` and `SpanSchema` are unchanged. - **`--verify-anchors` at `47b1f0bb7`:** exit 0. "55 asserting objectui pin citation(s) match .objectui-sha (47b1f0bb7), 144 historical citation(s) recorded and not checked, across 1932 spec source(s). 13 anchor content assertion(s) verified against objectui at 47b1f0bb7; 502 file:line anchor(s) seen." - `.changeset/objectui-pin-citations-47b1f0bb7174.md`: `@objectstack/spec` `patch`, in the precedent's shape. - ⛔ No schema shape, key, `describe` text (beyond the `span` describe's sha) or behaviour changes. ## Pins - **Runtime unit pin, per face:** `packages/runtime/src/domains/i18n-anonymous-deny.test.ts` (52 cases). - Anonymous gets 401 in both shapes the dispatcher produces (an unresolved context and the guest envelope). The test asserts `code`, `status` and the message, and that no bundle key and no `data` ride on the body. - The provider is never consulted: neither the slot lookup nor any provider method runs. - A missing locale and an empty slot still get 401. An unknown sub-path and a write verb get 401 too, so a face added later arrives behind the floor. - Signed-in control: a member is served exactly the body as before, a system context passes, an empty slot still answers a member 501, a member's missing locale still gets 400, and a CORS preflight stays outside the floor. - **Booted proof:** `showcase-anonymous-deny-surfaces.dogfood.test.ts` gains the three mounted faces. Each is driven anonymously (401, dispatcher-wrapper family, no bundle key) and by a signed-in member (200, the showcase's own `zh-CN` bundle and labels). Each anonymous body is classified into exactly one envelope family. The file claims the new matrix row. ## Translation-flip sweep Every pin and prose line that held "the domain answers without a session", all in this PR: - Six runtime suites drove the domain with no identity, as the smallest context that compiled. They now carry a signed-in caller: `AUTHED_CALLER`, a `SIGNED_IN` context, or the dispatcher's resolution seam stubbed the way the analytics cases already do. Only identity moved, and every expectation is unchanged. The suites: `http-dispatcher` (20 calls), `domain-handler-registry` (2), `http-dispatcher.multi-tenant-concurrency` (1), `i18n-success-envelope.conformance` (6), `i18n-supported-locales` (2) and `error-envelope.conformance` (1). - `http-dispatcher.multi-tenant-concurrency.test.ts` called the domain "anonymous-reachable (no `shouldDenyAnonymous` gate)". Each tenant kernel now carries a tagged `auth` slot that signs the request in. The probe is still the i18n read behind the floor, and the suite still parks request A inside its own identity resolution. - The anonymity half of each flipped pin carries weight in the two new pins above: status, `code`, nothing served. - `content/docs/permissions/system-context.mdx`, row 51 (the anonymous-deny seams on the domain dispatchers) now names `handleI18nRequest`. The page's census counts were regenerated by `pnpm gen:system-context-census` (the new floor reads `isSystem`). - Searched and found nothing to flip: `content/docs/**`, `skills/**`, `@objectstack/client`, the route ledgers (the domain's rows are `sdk`, not `public`), and the CLI. `http-conformance` authenticates every request with a stub session, so its 501 probe is unchanged. ## Ledgers (H4) - New `enforced` row `anonymous-deny-i18n`. It covers a GATE_PIN key on the domain's `shouldDenyAnonymous` call (`i18n:domains/i18n.ts:anonymous-gate`) and the dispatcher-domain key `dispatcher-domain:route-ledger.ts:/i18n`, the pairing the analytics row makes. Its cited proof is the booted file above. - `authz-ledger-population.baseline.ts`: the `/i18n` key leaves. MAX goes from 31 to 30, with a dated note. The population pin lists it as classified. - Probe census, re-derived from `deriveProbeFileCensus()` on the merged ref: - `PROBE_TABLE` moves from 19 / 14 / 18 to 20 / 15 / 20, and `MATRIX_HEADER_PROBE_CLAIM` from 19 to 20. A new `PROBE_FILE_CENSUS` row covers `domains/i18n.ts`: gate pin, population 1, blind spot 0, controls `shouldDenyAnonymous(` 1 and `handleI18nRequest` 3. - The census's handler-name class now admits digits. Read letters-only, `handleI18nRequest` counted as a population of 0 under a reach of 1. No other row's count moves (re-derived). - The dispatcher-domain (`route-ledger.ts`) row note now reads 21 domains, 7 classified, 14 baselined, and 9 domain files no probe names. It had read 5 / 16 and 11 files since before `/analytics` was classified. - The matrix header figures are re-measured from the rows: 20 probes over 15 files; 39 ledger keys, 9 classified and 30 baselined; 44 of 54 rows carry no `covers` (10 rows, 20 keys); 38 of 47 `enforced` rows are in-resolver; 7 of the 20 keys are gate pins. ## Reverse verification (from the committed fix) The mutation replaced the gate's condition with a never-true comparison against a planted literal, through `scripts/ablation-replace.mjs` (anchor hit x1, blob changed), inside a script carrying its own EXIT/INT/TERM restore. - Source-resolved legs: - The runtime unit pin went **32 failed / 20 passed**: every anonymous case red, every signed-in control and the preflight case green. - `authz-conformance.test.ts` and `authz-probe-blind-spot.test.ts` went **7 failed / 85 passed**, naming `STALE covers … i18n:domains/i18n.ts:anonymous-gate`, `DEAD PROBE … domains/i18n.ts`, and the census control. - Dist-resolved leg: - `@objectstack/runtime` was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. - `showcase-anonymous-deny-surfaces` went **7 failed / 69 passed**: the 3 anonymous faces (each read `expected 200 to be 401`), their 3 envelope classifications and the shared code-and-message case. All 3 member controls stayed green. - Restore: blob equal to HEAD, `git diff HEAD` empty. Rebuilt; the marker is absent from all 6 built files, and the tree is clean. - Direction: red as predicted, in every leg. ## Tests and gates - `@objectstack/runtime` at `5b97d08ae`: the full suite gave 349 files, 5681 passed, 19 skipped. `typecheck` exit 0: `tsc --noEmit` plus `check:test-typecheck`, whose program holds 7 of the 7 touched test files (counted with `--listFiles`). - `@objectstack/dogfood` at `5b97d08ae`: `typecheck` exit 0, with 6 of the 6 touched files in the program. The WHOLE suite, through the verify lock: 236 files (234 passed, 1 skipped, 1 failed), 1872 tests (1861 passed, 9 skipped, 2 failed), exit 1. The 2 failures are both in `external-import-destructive-remedy.dogfood.test.ts`, each `Test timed out in 5000ms`, while unlocked gate scripts ran beside it on the shared box. That file never touches the `/i18n` domain. Re-run alone through the lock at the same head: 4 of 4 passed, exit 0. - `pnpm lint` (the full run) exit 0 at `5b97d08ae`. - `node scripts/pm/dispatch-gates.mjs --commands` derives 107 commands at `5b97d08ae` (the dispatch's 82 plus the docs and scripts families this diff reaches). All 107 ran at `5b97d08ae`, each exit code captured before any pipe. 106 exited 0. The one red is `check:objectui-pin-citations` (exit 1), the gap at the top. `--ran` reconciles 107 derived, 107 run, 0 NOT-MEASURED (a derived zero: every line carries its exit code). - **Round 2, at `e7c00d422`, after merging `main` (`e148ca984`) in, without conflicts or a generated-artifact deferral:** - `dispatch-gates --commands` derives 128 commands; all 128 exit 0, `check:objectui-pin-citations` included. `--ran` reconciles 128 derived, 128 run, 0 NOT-MEASURED. Two of them, `check:console-sha` and `check:console-injection`, skipped for want of a Console dist in the fresh worktree: NOT MEASURED locally this round. Both were measured green in round 1 at the same pin, and CI's `Console Pin Gate` is green on `e7c00d422`. - `@objectstack/spec`: test 630 files / 18798 passed; typecheck exit 0; `check:generated` reports all 15 artifacts up to date. - `@objectstack/runtime`: the full suite 349 files / 5680 passed / 19 skipped (re-run because the merge touched `packages/runtime/src`). - Dogfood: the authz pair plus the booted proof, 3 files / 168 passed. The WHOLE suite, through the verify lock (re-run because the merge changed dogfood inputs: the verify harness now boots what `serve` composes): 236 files (235 passed, 1 skipped), 1871 tests (1862 passed, 9 skipped), exit 0. - Typecheck: `@objectstack/runtime` and `@objectstack/dogfood` both exit 0. A first pass red on both with TS7016 "Could not find a declaration file", because it ran while an unlocked gate (`check:type-check-debt`, its `--re-measure`) rebuilt the workspace `dist/`. Re-run through the lock after that: exit 0. - `pnpm lint` (the full run) exit 0 at `e7c00d422`. - **Round 3, at `d4357cac5`, after merging `main` (`ee8751d41`) in (merge `6656a940d`), no new behaviour:** - One text conflict, in `content/docs/permissions/system-context.mdx`, on the census line both sides had moved. `main` (#22513) brought row `9b` and its own +1 to the counts; this branch kept its row-51 anchor `packages/runtime/src/domains/i18n.ts#handleI18nRequest`. The counts were then re-derived by `pnpm gen:system-context-census`: 123 reads in 58 files, living in 105 symbols. `registry.ts` text-merged, and `gen:migration-registry` rewrote it byte-identical. - `main`'s `4e9fe9ff6` (PROTOCOL_VERSION 17 → 18, #22215) began rendering the 18.* semantic entries into `packages/spec/spec-changes.json` and `docs/protocol-upgrade-guide.md`. Six of them carry this PR's re-measured pin readings, so both artifacts read stale on the merged tree: `check:spec-changes`, `check:upgrade-guide` and `check:generated` each exit 1. They were regenerated by `gen:spec-changes` and `gen:upgrade-guide` in `d4357cac5`: +12/-12 and +6/-6, the six entries' rationale text only. - `dispatch-gates --commands` derives 128 commands, and all 128 exit 0 at `d4357cac5`. `--ran` reconciles 128 derived, 128 run, 0 NOT-MEASURED. `check:console-sha` and `check:console-injection` again skipped for want of a Console dist, so they are NOT MEASURED locally. - `@objectstack/spec` at `d4357cac5`: test 631 files / 18833 passed. `@objectstack/runtime` at `6656a940d`: 349 files / 5680 passed / 19 skipped. Dogfood at `6656a940d`: the authz pair plus the booted proof, 3 files / 168 passed. The WHOLE dogfood suite: 237 files (236 passed, 1 skipped), 1881 tests (1872 passed, 9 skipped), exit 0. The typecheck of spec, runtime and dogfood exits 0. `d4357cac5` touches only the two generated files, and neither the runtime suite nor the dogfood suite reads them. - **The contract face did not move:** `git diff` over `packages/spec`, with the generated `registry.ts` and `spec-changes.json` excluded, hashes `921af3c8abc9…` both at `e7c00d422` (against `e148ca984`) and at `d4357cac5` (against `ee8751d41`); the seat re-computed both. The contract review `6086141672` therefore still covers this PR's contract face. - **Round 4, at `a252adcef`, after merging `main` (`f782f1764`, which carries `ce78ff7bc`, the #22541 CI fix) and regenerating `spec-changes.json` and the upgrade guide through their generators:** - CI on `a252adcef`: 34 success and 1 roster skip (Packed-tarball smoke), `Temporal Conformance` included. - **The contract face is identical at all three reviewed heads** (`e7c00d422`, `d4357cac5`, `a252adcef`), each diffed against its own merge base over `packages/spec` with the generated `registry.ts` and `spec-changes.json` excluded. The seat measured it three ways: `--full-index` `e66dc13a8567…`; with the `index` lines dropped, `926808406aae…`; with `--abbrev=10`, `48d27a3badea…`. The round-3 figure `921af3c8…` is the same diff under a shorter blob abbreviation. - Against `main` `86bf9ed7d5`, a plain three-way text merge of `spec-changes.json` and the upgrade guide (as the merge queue does it) has no conflict: this PR's hunks and `main`'s are disjoint. ## File surface beyond the claim, declared - `content/docs/permissions/system-context.mdx`: one anchor on row 51, plus the census counts its generator rewrote. `check-system-context-census` is red without it, because the floor reads `ExecutionContext.isSystem`. I read it as the claim's docs bullet (a hand-written page stating which domains hold the anonymous floor). The seat accepted that reading and declared the page on #6023 (`6083343996`). - The six runtime suites under "Translation-flip sweep" sit beside the handler under test, and the dispatch's sweep clause ordered them flipped. - `packages/spec` (the 15 files whose records cited the old pin; anchors and shas only), the generated `content/docs/references/ui/view.mdx`, and `.changeset/objectui-pin-citations-47b1f0bb7174.md`: the seat widened the claim to the pin bump's citation re-measure in `6083306429`, and declared it to `domain:spec` on #18549 and #6017. - `packages/spec/spec-changes.json` and `docs/protocol-upgrade-guide.md` (round 3): generator output only. Both were re-derived on the merged tree from the six claimed `18.*-unit-in-key.ts` entries, once `main`'s protocol-18 bump began rendering those entries. No source edit. ## Acceptance notes - **The objectui starter gap, recorded, not fixed here** (pointer `6077215779` on the card, seat reading `6078635289`). objectui's `examples/console-starter` loads translations with a bare `fetch` and signs in in place. Once this lands, a starter user sees untranslated application labels after sign-in until a reload. No raw key is shown: the readers fall back to the authored literal. objectui's seat owns any starter card. - **The cloud row:** cloud's objectui pin must carry `47b1f0bb7174` no later than its framework pin carries this refusal. That row on objectstack-ai/cloud#2709 belongs to the landing seat. - **Stale figures, corrected here because the touched rows own them:** the matrix header still read 52 rows, 8 rows with `covers`, 17 keys and 45 `enforced` after PR 22446 (53 / 9 / 18 / 46 measured at the base). The census's dispatcher-domain note had read 5 / 16 and 11 files since `/analytics`. - **Observation, not filed:** `I18nServicePlugin.registerI18nRoutes` mounts the same three paths with no anonymous floor of its own. In every in-repo composition (`os serve`, `os dev`, `DevPlugin`) the dispatcher's gated mounts register first and answer; this boot measured that. A host that composes `I18nServicePlugin` on an HTTP server without the runtime dispatcher plugin was not measured. It is listed in the card report for the seat, with the ADR-0138 everything-else re-measure as its natural carrier. - **Documentation drift, not touched:** the `anonymous-deny.ts` docblock in `@objectstack/core` still says "the five runtime domains", and the table header in `packages/runtime/src/endpoint-policy.ts` is in the same state. Neither names `/packages`, `/analytics` or now `/i18n`. Carrier: none. - **QA checklist, not extended:** `access-security.anonymous-deny-surfaces` does not gain an i18n variant, and the `i18n.json` item's server-truth step reads the three faces without saying it signs in first. A runner who reads them anonymously now gets 401. Carrier: none. - **Disclosure:** function level throughout, as the ruling requires. - The changeset is `minor` with the BREAKING banner, the launch-window convention for a door narrowing. ADR-0087 disposition: `not-required (no-migration-prescription)`. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4638625 commit b53b949

39 files changed

Lines changed: 937 additions & 183 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the `/i18n` dispatcher domain refuses a caller without a session with `401 UNAUTHENTICATED`, like every other dispatcher domain
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at the dispatcher's /i18n domain handler (handleI18nRequest), not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and what a signed-in caller receives is unchanged, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers the domain serves: a caller with no session is now refused with the shared anonymous-deny 401 before the domain reads anything. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this domain and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The `/i18n` dispatcher domain serves the application's translations: the locale list, the translation bundle and the field labels. The bundle carries the labels of every object, field, app and page the application declares. Until now the domain served a caller with no session, while the metadata read of the same objects refused one. ADR-0056 D2 denies anonymous callers by default.
14+
15+
**What changed.** The domain handler opens with the shared anonymous-deny decision (`shouldDenyAnonymous`), the same floor the `/meta`, `/actions`, `/automation`, `/packages` and `/analytics` domains stand on. It is the handler's first statement:
16+
17+
- every face of the domain answers a caller without a session `401` with code `UNAUTHENTICATED`, in the dispatcher's wrapped envelope (`{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`), and serves nothing of the bundle;
18+
- it runs before the i18n provider is looked up, so the answer is `401` whether or not a provider is installed, never the `501` an empty slot answers;
19+
- it runs before a face reads its parameters, so a request that leaves out its locale is `401`, never the `400` that face answers.
20+
21+
**What is not affected.** A signed-in caller, an API-key caller and an internal system context are served exactly as before: the same bundle, the same `400` for a missing locale, the same `501` when no provider is installed. A CORS preflight is unchanged.
22+
23+
**The Console.** The Console this release pins renders its sign-in page from its built-in language packs and loads the application's translations after sign-in, with the signed-in session. A signed-in Console user sees the application's labels as before.
24+
25+
**If you read `/i18n` from your own client,** send the signed-in user's session or bearer token, or an API key, with the request. Render anything shown before sign-in from strings your client ships, and load the application's translations once the user has signed in.

‎.changeset/console-47b1f0bb7174.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/console": patch
3+
---
4+
5+
Console (objectui) refreshed to `47b1f0bb7174`. Frontend changes in this range:
6+
7+
Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 14 non-merge commits; omitted: 1 release-nothing changeset (they ship no package code).
8+
9+
- **patch** — The console's sign-in page no longer reads `/api/v1/i18n`, and the application's translations and locale list load with the session's credentials once signed in (objectui#12034). (objectui `47b1f0bb7`)
10+
- **patch** — The record approvals panel draws its decision-progress tally through one module-internal indicator, `DecisionProgressIndicator` (objectui#12033, part of objectui#2763). The tally… (objectui `ba9e82026`)
11+
- **patch** — The Studio header's *More* trigger keeps its own name (objectui#11794). While *Access*, the pillar it holds, was open, the trigger renamed itself to "Access", which hid the word t… (objectui `8de8ba280`)
12+
- **patch** — The embedded item editor ("Save into object", opened from a metadata item's Related drawer) now saves into the parent's draft (objectui#12027). (objectui `ea79b7777`)
13+
- **patch** — A compact record-preview card for any `(object_name, record_id)` pair, kept inside the package for the approval surfaces to compose (objectui#12029, the first child of objectui#27… (objectui `049012bf0`)
14+
- **patch** — Studio navigation details (objectui#11794): (objectui `8f815f4fe`)
15+
- **patch** — Three more controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the list view and the chatbot): `ListView`'s "Color by field" an… (objectui `8f8f760fa`)
16+
- **patch** — Studio saves one way on a package: the permission matrix and hooks autosave to the package draft like the other pillars, every create dialog says *Save as draft*, and the Changes… (objectui `6694abe75`)
17+
- **patch** — Five of the Studio design surface's pickers use the shared `Select`, the control the rest of Studio picks with (objectui#11865, the design surface's part of that card): in the nav… (objectui `5382a865f`)
18+
- **patch** — Four plugin controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the plugins' single selects): `SharedViewLink`'s "Expires after… (objectui `2063f7a96`)
19+
- **patch** — A quick-filter value restored from the URL now gets its field's type once the object definition loads, when the field is declared without its type (objectui#12008). (objectui `16b9d440d`)
20+
- **patch** — Four metadata-admin pickers use the shared `Select`, the control the rest of the console picks with (objectui#11865, the metadata-admin previews and inspectors' part of that card)… (objectui `f2bff5ce8`)
21+
- **patch** — The Create View dialog, the AI build panel's Excel import bar and the API console's method selector pick with the shared `Select`, the control the rest of the console picks with (… (objectui `869d0bfdf`)
22+
23+
objectui range: `f0268ad78485...47b1f0bb7174`
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `47b1f0bb7174`.
6+
7+
Clause-②: no
8+
9+
Every anchor was mapped through the objectui diff `f0268ad78485..47b1f0bb7174`: 129 paths over 14 commits, none deleted or renamed. Two files a record cites changed on the hop, each for objectui#11865 (a picker drawn with the shared `Select`): `ObjectGrid.tsx` gained one import line and redrew its grouped pager's rows-per-page picker, and `ListView.tsx` redrew its "Color by field" and rows-per-page pickers. Every cited line in those two files moved with its text byte-identical, so the nine records that cite them are re-pointed, and each hop sentence says by how much. The other 34 records cite only files that are byte-identical across the hop, and each gains a hop sentence that says so.
10+
11+
No record says anything false at the new pin, so no reading is rewritten.
12+
13+
The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `f0268ad78485` number. The corpus is now 8281 tracked files. Every token an entry counts as zero still reads zero: none of them occurs on a line the hop adds or removes. The controls moved with the corpus, for example `objectstack` from 17956 to 17980 and `timeout` from 1658 to 1674.
14+
15+
No key, default, enum member or export moves.

‎.objectui-sha‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
f0268ad784854568aa58a2aa791f6a7502259186
1+
47b1f0bb71748a7d16f36edecc50059367d2e35a

‎content/docs/permissions/system-context.mdx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **122
13+
because the flag is not one concept: it is a single boolean read at **123
1414
distinct sites across 20 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -143,7 +143,7 @@ that silently does not happen.
143143

144144
### 3. Sharing (`plugin-sharing`)
145145

146-
The largest single consumer — **17 of the 122 sites**.
146+
The largest single consumer — **17 of the 123 sites**.
147147

148148
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
149149
|:--|:---|:---|:---|
@@ -177,7 +177,7 @@ The largest single consumer — **17 of the 122 sites**.
177177
| 48 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` |
178178
| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict` |
179179
| 50 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 49's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` |
180-
| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
180+
| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
181181
| 52 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` |
182182
| 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` |
183183
| 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` |
@@ -286,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
286286
independent decisions, and a seed loader plausibly wants the first two but not
287287
the third. The concept is nevertheless **staying as one boolean**:
288288

289-
- **Shipped semantics.** `isSystem` is a published contract with 122 read sites
289+
- **Shipped semantics.** `isSystem` is a published contract with 123 read sites
290290
in 20 packages. Splitting it is a breaking contract change across all of them.
291291
(The ruling was taken when the census read 80 sites in 18 packages; the count
292292
has grown, which strengthens rather than weakens the argument.)
@@ -360,16 +360,16 @@ still holds equal to the census on every pull request:
360360
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
361361
| — parsed as a declaration | 28 | ✅ |
362362
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
363-
| — parsed as a property **read** | 128 | ✅ |
363+
| — parsed as a property **read** | 129 | ✅ |
364364
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
365365
| — the remainder: text inside comments and string literals | 358 | — |
366366
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
367-
| Of those reads: reads of `ExecutionContext.isSystem` | **122** | ✅ |
368-
| — behaviour-bearing (rows 1–61 above) | 119 | ✅ |
367+
| Of those reads: reads of `ExecutionContext.isSystem` | **123** | ✅ |
368+
| — behaviour-bearing (rows 1–61 above) | 120 | ✅ |
369369
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
370370
| Packages containing at least one elevation read | **20** | ✅ |
371-
| Files containing at least one elevation read | 57 | ✅ |
372-
| — the distinct symbols those reads live in — what this page anchors | 104 | ✅ |
371+
| Files containing at least one elevation read | 58 | ✅ |
372+
| — the distinct symbols those reads live in — what this page anchors | 105 | ✅ |
373373
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
374374

375375
The six rows marked — are a **dated decomposition, not a live claim**: they were
@@ -433,7 +433,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
433433
Renaming a symbol is now a loud red instead of a silent misdirection.
434434

435435
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
436-
cannot say WHICH read inside a function it means, and **8** of the **57**
436+
cannot say WHICH read inside a function it means, and **8** of the **58**
437437
anchored files hold more than one read inside a single symbol. So the population
438438
check runs per file at symbol granularity: every file the census finds a read in
439439
must be anchored, and the set of symbols this page cites into that file must

‎content/docs/references/ui/view.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ Column footer summary configuration
213213
| **required** | `boolean` | optional | Required override |
214214
| **hidden** | `boolean` | optional | Hidden override |
215215
| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. |
216-
| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `f0268ad78485`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. |
216+
| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `47b1f0bb7174`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. |
217217
| **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) |
218218
| **language** | `string` | optional | Code editor language (for type=code) |
219219
| **keyField** | `{ field?: string; label?: string \| Record<string, string>; placeholder?: string \| Record<string, string>; helpText?: string \| Record<string, string>; … }` | optional | Key column config for record-typed fields |
@@ -343,7 +343,7 @@ Form-view select option — the object-field option shape minus the per-option `
343343
| **required** | `boolean` | optional | Required override |
344344
| **hidden** | `boolean` | optional | Hidden override |
345345
| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. |
346-
| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `f0268ad78485`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. |
346+
| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `47b1f0bb7174`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. |
347347
| **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) |
348348
| **language** | `string` | optional | Code editor language (for type=code) |
349349
| **keyField** | `{ field?: string; label?: string \| Record<string, string>; placeholder?: string \| Record<string, string>; helpText?: string \| Record<string, string>; … }` | optional | Key column config for record-typed fields |

0 commit comments

Comments
 (0)