@starci/hfs 4.7.44 → 4.7.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.45 - 2026-10-26
4
+
5
+ - Changed: the alpha.31.1 round folded into alpha.31; the bundled canon pins and runtime copies follow the final package versions. No rule changes.
6
+
3
7
  ## 4.7.44 - 2026-10-26
4
8
  - Changed: the bundled canon pins follow the lint family bumps (canon-be 3.4.51, canon-fe 9.1.6, Grammar 0.12.10) and the bundled runtime copy carries the wave version. No rule changes.
5
9
 
package/lint/run.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  // turbo `lint` task); the root `npm run lint` stays the whole app. There is no second linter: a workspace lint is this lint, scoped.
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
- import { createRequire, registerHooks } from 'node:module';
20
+ import { createRequire } from 'node:module';
21
21
  import { spawn } from 'node:child_process';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
23
  import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
@@ -168,17 +168,53 @@ export function canonScope(config) {
168
168
  };
169
169
  }
170
170
 
171
+ /** The conditions the import below resolves with: `node` and `import`, plus `default` as exports' terminal fallback. */
172
+ const IMPORT_CONDITIONS = new Set(['node', 'import', 'default']);
173
+
174
+ /** The first import target of an exports entry: a string, a fallback array, or a conditions object matched in its own key order. */
175
+ function exportsTarget(entry) {
176
+ if (typeof entry === 'string') return entry;
177
+ if (Array.isArray(entry)) {
178
+ for (const item of entry) {
179
+ const target = exportsTarget(item);
180
+ if (target !== null) return target;
181
+ }
182
+ } else if (entry !== null && typeof entry === 'object') {
183
+ for (const [condition, target] of Object.entries(entry)) {
184
+ if (IMPORT_CONDITIONS.has(condition)) {
185
+ const resolved = exportsTarget(target);
186
+ if (resolved !== null) return resolved;
187
+ }
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /** The import entry of a package found by the app's own node_modules lookup (the folders require.resolve.paths lists from cwd). */
194
+ export function importEntry(require, packageName) {
195
+ for (const dir of require.resolve.paths(packageName) ?? []) {
196
+ const manifest = path.join(dir, packageName, 'package.json');
197
+ if (!fs.existsSync(manifest)) continue;
198
+ const published = JSON.parse(fs.readFileSync(manifest, 'utf8'));
199
+ if (published.exports === undefined) return path.join(dir, packageName, published.main ?? 'index.js');
200
+ const exports = published.exports;
201
+ const entry = exports !== null && typeof exports === 'object' && !Array.isArray(exports)
202
+ && Object.keys(exports).some((key) => key.startsWith('.')) ? exports['.'] : exports;
203
+ const target = exportsTarget(entry);
204
+ if (target === null) throw new Error(`no "exports" import target for "." in ${manifest}`);
205
+ const file = path.join(dir, packageName, target);
206
+ if (!fs.existsSync(file)) throw new Error(`the "exports" import target of ${manifest} does not exist: ${target}`);
207
+ return file;
208
+ }
209
+ throw new Error(`Cannot find package '${packageName}'`);
210
+ }
211
+
171
212
  /** Resolve one canon from this app's install; a package from an enclosing repository cannot prove its lint contract. */
172
- function ownCanonEntry({ repoRoot, cwd }, packageName) {
213
+ export function ownCanonEntry({ repoRoot, cwd }, packageName) {
173
214
  const require = createRequire(path.join(cwd, 'package.json'));
174
- // The entry is imported below, so resolve with Node's import conditions even when a package publishes no require target.
175
- // The synchronous hook exists only for this lookup; later package resolution keeps its own conditions.
176
- const hook = registerHooks({ resolve: (specifier, context, nextResolve) => nextResolve(specifier, specifier === packageName
177
- ? { ...context, conditions: [...new Set([...context.conditions.filter(condition => condition !== 'require'), 'import'])] }
178
- : context) });
179
- let entry;
180
- try { entry = require.resolve(packageName); }
181
- finally { hook.deregister(); }
215
+ // The entry is imported below, so the package's import condition applies; require.resolve cannot be told so on every
216
+ // supported Node (its registerHooks resolve hook skips require.resolve before Node 24), so "." is matched directly.
217
+ const entry = importEntry(require, packageName);
182
218
  // The canon must be the app's own install: resolved inside the app root, or inside the app's node_modules when that folder is a link to the install
183
219
  // (the runtime's examples lint in a sealed copy whose node_modules links the example's own install, scripts/hfs/example-secrets.mjs).
184
220
  const ownInstall = [repoRoot, path.join(repoRoot, 'node_modules')].filter((dir) => fs.existsSync(dir)).map((dir) => fs.realpathSync(dir));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.44",
3
+ "version": "4.7.45",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,8 @@
16
16
  "smol-toml": "1.9.0"
17
17
  },
18
18
  "devDependencies": {
19
+ "@typescript-eslint/eslint-plugin": "8.71.0",
20
+ "@typescript-eslint/parser": "8.71.0",
19
21
  "@graphql-codegen/cli": "7.4.6",
20
22
  "@graphql-codegen/client-preset": "6.2.3",
21
23
  "@nestjs/common": "11.2.5",
@@ -23,6 +25,7 @@
23
25
  "@nestjs/graphql": "13.4.5",
24
26
  "@nestjs/testing": "11.2.5",
25
27
  "class-validator": "0.15.1",
28
+ "eslint": "10.11.0",
26
29
  "graphql": "16.14.2",
27
30
  "prettier": "3.9.6",
28
31
  "reflect-metadata": "0.2.2",
@@ -0,0 +1,8 @@
1
+ // supabase-bucket.mjs - select storage configuration from a local stack's migrated bucket ids.
2
+
3
+ /** Returns no bucket variable for no ids, selects the sole id, and refuses ambiguous storage with every id listed. */
4
+ export function storageBucketEnv(ids) {
5
+ if (ids.length === 0) return {};
6
+ if (ids.length === 1) return { STORAGE_S3_BUCKET: ids[0] };
7
+ throw new Error(`Local Supabase storage has multiple buckets: ${ids.map((id) => JSON.stringify(id)).join(', ')}; app configuration must name which bucket to use`);
8
+ }
@@ -187,7 +187,17 @@ shapes:
187
187
  compose: |
188
188
  Every bar control uses `size="control"`, the 2.75rem shared height. Icon uses the family
189
189
  size-5 token (20px) throughout the region. SearchField or its trigger shows Ctrl K and opens
190
- on Ctrl/Cmd+K; inside a surface its variant resolves through the nesting context.
190
+ on Ctrl/Cmd+K; inside a surface its variant resolves through the nesting context. A compact
191
+ language switch in a bar is SegmentedControl size="control" (the HeroUI lg group held to
192
+ 2.75rem); md and lg keep HeroUI's own sizes.
193
+ - id: site-chrome-bar
194
+ implements: [FE-APP-SHELL-3]
195
+ compose: |
196
+ A public site composes its bar inside the flow shell, in one module (the scaffold SiteShell):
197
+ WorkspaceShell viewport="flow" header={TopBar brand navigation actions}. navigation is a
198
+ NavLandmark layout="bar" of TextAction links; actions is the language SegmentedControl
199
+ size="control", then the sign-in Button intent="neutral" and the call-to-action Button, all
200
+ size="control". The app right cluster (menu, theme Switch, bell, account) is for the app shell only.
191
201
  - id: workspace-identity
192
202
  implements: [FE-APP-SHELL-2]
193
203
  compose: |
@@ -22,36 +22,43 @@ pins:
22
22
  side: fe
23
23
  source: packages/heroicons/package.json
24
24
  why: 'StarCi custom Heroicons-compatible cuts; eslint-grammar-starci (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
25
+ '@starci/event-stream':
26
+ version: 0.1.0
27
+ group: starci
28
+ install: registry
29
+ side: fe
30
+ source: packages/event-stream/package.json
31
+ why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
25
32
  '@starci/grammar':
26
- version: 0.12.10
33
+ version: 0.12.12
27
34
  group: starci
28
35
  install: registry
29
36
  side: fe
30
37
  source: packages/grammar/package.json
31
38
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
32
39
  '@starci/eslint-canon-be':
33
- version: 3.4.51
40
+ version: 3.4.54
34
41
  group: starci
35
42
  install: registry
36
43
  side: be
37
44
  source: packages/eslint/be/package.json
38
45
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
39
46
  '@starci/eslint-canon-fe':
40
- version: 9.1.6
47
+ version: 9.1.8
41
48
  group: starci
42
49
  install: registry
43
50
  side: fe
44
51
  source: packages/eslint/fe/package.json
45
52
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
46
53
  '@starci/eslint-grammar-starci':
47
- version: 0.12.10
54
+ version: 0.12.12
48
55
  group: starci
49
56
  install: registry
50
57
  side: fe
51
58
  source: packages/eslint/grammar-starci/package.json
52
59
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
53
60
  '@starci/eslint-grammar-offset-pop':
54
- version: 0.12.10
61
+ version: 0.12.12
55
62
  group: starci
56
63
  install: registry
57
64
  side: fe
@@ -91,14 +98,14 @@ pins:
91
98
  source: packages/test-world/package.json
92
99
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
93
100
  '@starci/cli':
94
- version: 1.7.44
101
+ version: 1.7.45
95
102
  group: starci
96
103
  install: registry
97
104
  side: both
98
105
  source: packages/cli/package.json
99
106
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
100
107
  '@starci/hfs':
101
- version: 4.7.44
108
+ version: 4.7.45
102
109
  group: starci
103
110
  install: registry
104
111
  side: both
@@ -82,7 +82,18 @@ concerns:
82
82
  home: [fe.hooks.socketio]
83
83
  pattern: FE-REALTIME
84
84
  forbids: [subscriptions-transport-ws, '@supabase/realtime-js', pusher-js, ably]
85
- why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; both editions run the gateway in the be api process. A one-way channel served as server-sent events needs no package, only a stream reader in modules/api/sse/clients, a stream operation in modules/api/sse and a useSubscribe<Channel>Swr hook (FE-REALTIME).
85
+ why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; both editions run the gateway in the be api process. A one-way channel served as server-sent events is the event-stream concern below (FE-REALTIME).
86
+ - id: event-stream
87
+ tier: opt
88
+ packages: ['@starci/event-stream']
89
+ home: [fe.transport.stream]
90
+ pattern: FE-REALTIME
91
+ forbids: ['@microsoft/fetch-event-source', eventsource, event-source-polyfill, eventsource-parser, reconnecting-eventsource, 'sse.js', 'fetch-sse', 'sse-ts']
92
+ why: >-
93
+ the one server-sent-events client (owner:event-stream-package): the app creates one instance in modules/api/sse/clients/event-stream.ts
94
+ with createEventStream (the bearer token source, one renewal on a 401, backoff, heartbeat timeout), each stream operation
95
+ (sse/<area>/stream-<channel>.ts) follows on it, and the useSubscribe<Channel>Swr hook reads that operation. No app keeps a
96
+ hand-written reader (read-event-stream.ts) and no second SSE library is installed.
86
97
  - id: auth
87
98
  tier: core
88
99
  packages: []
@@ -5576,22 +5576,26 @@ rules:
5576
5576
  templateNa: "The app composes its shell from product destinations and identity; full and lite example shells provide the reference."
5577
5577
  - id: R318
5578
5578
  code: "FE_NESTED_VARIANT"
5579
- law: "Apps compose Grammar with layout-only CSS; paint utilities, arbitrary paint properties and inline style paint belong to Grammar. Grammar context owns all layer-aware field variants, nested Tabs defaults and the content edge of panel lists; apps omit static field variants, do not force primary Tabs inside a painted surface, do not paint raw surfaces, and add no horizontal list-wrapper padding within the nearest painted Grammar surface."
5579
+ law: "Apps compose Grammar with layout-only CSS; paint utilities, arbitrary paint properties and inline style paint belong to Grammar. Grammar context owns all layer-aware field variants, nested Tabs defaults and the content edge of panel lists; apps omit static field variants, do not force primary Tabs inside a painted surface, do not paint raw surfaces, and add no horizontal list-wrapper padding within the nearest painted Grammar surface. BOUNDARY-1 case-5 owns Divider full-bleed composition."
5580
5580
  kinds: [lint, design]
5581
5581
  gates: [pre-commit, settle, land, ci]
5582
- failureCodes: ["FE_NESTED_VARIANT", "FE_LIST_PANEL_PADDING", "FE_GRAMMAR_LAYOUT_ONLY"]
5582
+ failureCodes: ["FE_NESTED_VARIANT", "FE_LIST_PANEL_PADDING", "FE_GRAMMAR_LAYOUT_ONLY", "FE_DIVIDER_FULL_BLEED"]
5583
5583
  enforcers:
5584
5584
  - {kind: eslint-fe, id: nested-control-variant}
5585
5585
  - {kind: eslint-fe, id: no-list-panel-padding}
5586
5586
  - {kind: eslint-fe, id: no-app-surface-paint}
5587
+ - {kind: eslint-fe, id: divider-full-bleed}
5587
5588
  editions: [lite, full]
5588
5589
  docs: ["docs/code-pattern-enforcement.md#r318-app-grammar-composition"]
5589
5590
  templateNa: "Product composition is proved by the FE canon's Grammar import and surface fixtures; a template cannot choose product slots or lists."
5590
5591
  proves:
5592
+ - {path: packages/eslint/fe/divider-full-bleed.spec.mjs, editions: [lite, full], codes: [FE_DIVIDER_FULL_BLEED]}
5591
5593
  - {path: packages/eslint/fe/surface-rules.spec.mjs, editions: [lite, full], codes: [FE_NESTED_VARIANT, FE_LIST_PANEL_PADDING, FE_GRAMMAR_LAYOUT_ONLY]}
5592
5594
  exemplifiedBy:
5595
+ - {path: packages/eslint/fe/divider-full-bleed.spec.mjs, editions: [lite, full], codes: [FE_DIVIDER_FULL_BLEED]}
5593
5596
  - {path: packages/eslint/fe/surface-rules.spec.mjs, editions: [lite, full], codes: [FE_NESTED_VARIANT, FE_LIST_PANEL_PADDING, FE_GRAMMAR_LAYOUT_ONLY]}
5594
5597
  witness:
5598
+ - {path: packages/eslint/fe/divider-full-bleed.spec.mjs, editions: [lite, full], codes: [FE_DIVIDER_FULL_BLEED]}
5595
5599
  - {path: packages/eslint/fe/surface-rules.spec.mjs, editions: [lite, full], codes: [FE_NESTED_VARIANT, FE_LIST_PANEL_PADDING, FE_GRAMMAR_LAYOUT_ONLY]}
5596
5600
  cases:
5597
5601
  - id: case-1
@@ -305,7 +305,7 @@ ruleParams:
305
305
  - {typeSuffix: Options, names: [options], nameSuffix: Options}
306
306
  # R89 BE_SOURCE_FORM: the closed role-suffix vocabulary of a source file name (<kebab-name>.<suffix>.ts; BE-CONVENTION 1.15)
307
307
  # and the suffixes that are never a role. index.ts, main.ts, app.module.ts and the migrations of be.persistence are the
308
- # only names outside it.
308
+ # only names outside it. Typed realtime topic builders use the existing policy role (BE-REALTIME-3 of knowledge/patterns/be/realtime.yaml).
309
309
  suffixes: [module, module-definition, decorators, options, config, connection, service, schema, command, query, handler, templates, compensation, saga-state, saga-step, saga, contracts, port, resolver, controller, gateway, webhook, subscription, consumer, cli, event, queue, projection, projection-entity, processor, step, input, type, args, request, response, mapper, policy, entity, sql, rows, error, messages, log-events, cache-keys, guard, interceptor, filter, client, adapter, spec, integration-spec, e2e-spec, contract-spec, global-setup, builder]
310
310
  # R47 BE_CONTRACT_UNGUARDED: the world's shape vocabulary. `helper` names the function a contract spec imports to erase the values of a JSON
311
311
  # payload (`shapeOf`); a contract spec proves a fake's fixtures by asserting helper(real) equals helper(fixture).
@@ -1617,7 +1617,7 @@ slots:
1617
1617
  requires: [index.ts]
1618
1618
  # <name>.templates.ts holds the capability's static templates, copy and presets as TS constants (owner ruling 2026-10-13);
1619
1619
  # user-editable ones are seed rows, large or user media live in object storage.
1620
- allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts", "*.schema.ts", "*.schema.spec.ts"]
1620
+ allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts", "*.schema.ts", "*.schema.spec.ts", "*.policy.ts", "*.policy.spec.ts"]
1621
1621
  forbids: [testing/, exceptions/, utils/, helpers/, shared/]
1622
1622
  tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts, nothing else
1623
1623
  coverage: required
@@ -2336,8 +2336,8 @@ slots:
2336
2336
  # The browser's calls to the back end (owner ruling 2026-10-13, fe-stack graphql/rest/codegen, FE-API-GRAPHQL): the auth and
2337
2337
  # no-auth Apollo clients with their link chain (graphql/clients), one file per operation (graphql/queries/query-<x>.ts,
2338
2338
  # graphql/mutations/mutation-<x>.ts and graphql/subscriptions/subscription-<x>.ts, each with its gql document map and typed request/response in types/), the generated wire types
2339
- # (graphql/__generated__), the axios calls (rest/<area>/*.ts) for uploads, presigned URLs and auth endpoints and the fetch readers of
2340
- # server-sent-events streams (sse/<area>/stream-<channel>.ts, one operation per stream, run on the stream reader of sse/clients). A repository has
2339
+ # (graphql/__generated__), the axios calls (rest/<area>/*.ts) for uploads, presigned URLs and auth endpoints and the
2340
+ # server-sent-events streams (sse/<area>/stream-<channel>.ts, one operation per stream, run on the app's @starci/event-stream instance in sse/clients). A repository has
2341
2341
  # exactly ONE client folder and ONE GraphQLResponse union: the app's (fe.transport.client/outcome) when one app calls the back end,
2342
2342
  # or the api package's (fe.package.api.client/outcome) when several do (FE_TRANSPORT_OWNER, machine).
2343
2343
  requires: [index.ts]
@@ -2381,7 +2381,7 @@ slots:
2381
2381
  tier: transport
2382
2382
  allows: ["*.ts"]
2383
2383
  tests: none
2384
- why: "the fetch reader of server-sent-events streams of the one app that calls the back end, the stream half of the transport client layer (FE-REALTIME): it opens the stream with the bearer access token, parses the events and reopens it with Last-Event-ID after a capped, doubling wait; a stream operation (sse/<area>/stream-<channel>.ts) runs on it and a useSubscribe hook of hooks/swr reads that operation; a fetch here asks for text/event-stream and only a stream operation imports the reader (FE_TRANSPORT_OWNER); a repository where several apps stream keeps one reader in each of them"
2384
+ why: "the event-stream client of the one app that calls the back end, the stream half of the transport client layer (FE-REALTIME): event-stream.ts creates the app's one @starci/event-stream instance (createEventStream with the bearer access token source, which opens the stream with fetch, parses the events and reopens it with Last-Event-ID after a capped, doubling wait); a stream operation (sse/<area>/stream-<channel>.ts) follows on it and a useSubscribe hook of hooks/swr reads that operation; no hand-written reader lives here (owner:event-stream-package) and only a stream operation imports this folder (FE_TRANSPORT_OWNER); a repository where several apps stream keeps one instance in each of them"
2385
2385
  rules: [FE_TRANSPORT_OWNER]
2386
2386
  exemplifiedBy:
2387
2387
  - {path: packages/eslint/fe/r50-transport-owner-coverage.spec.mjs, editions: [lite, full]}
@@ -7,7 +7,7 @@ purpose: |-
7
7
  (`transport/sse/<channel>.controller.ts`), a socket gateway (`<channel>.gateway.ts`) or a GraphQL subscription
8
8
  (`<x>.subscription.ts`), that READS and PUSHES only. It never writes. The push is fed by a reactor: an event consumer in
9
9
  `features/reactors/` receives the domain event and publishes to a topic of the `RealtimeHub` of `platform/realtime` (an app
10
- with no event bus lets the domain service that commits the change publish, after its commit); the door only subscribes the
10
+ with no event bus lets the domain service that commits the change publish with the hub's commit ordering, BE-REALTIME-7); the door only subscribes the
11
11
  client to a topic of that hub, scoped to the client's principal. SSE is the default door for one-way server-to-client push
12
12
  (job progress, a status that changes, streamed tokens), in lite and in full (owner decision 2026-10-24): it is plain HTTP
13
13
  behind the ingress, reconnects by itself and resumes from `Last-Event-ID`. The socket.io gateway stays for a channel that also
@@ -28,7 +28,8 @@ provenance:
28
28
  - owner decision 2026-10-24 (the lite edition may serve SSE, and SSE is the default door for one-way push; ruling:R212/case-5, amended by owner:lite-graphql-subscription-door and ruling:R212/case-7 for the GraphQL subscription door)
29
29
  limitations: |-
30
30
  Static checks prove the door's role, that the hub is the only thing injected and that a subscribed topic is built from the
31
- principal. They do not prove the topic naming is collision free across channels, that an initial snapshot is consistent with
31
+ principal. They do not prove the publisher's membership read, that a non-Postgres publish follows the domain commit,
32
+ that the topic naming is collision free across channels, that an initial snapshot is consistent with
32
33
  the stream, or the behaviour of the socket under reconnects; the three `realtime/*` scenarios prove delivery, isolation and
33
34
  cleanup on the real stack. Nothing proves the ingress settings of an SSE channel (no compression, no buffering, HTTP/2, an
34
35
  idle timeout above the heartbeat): they are reviewed against the stack files and a real stream. The memory hub is
@@ -127,8 +128,8 @@ rules:
127
128
  needs is pushed by a reactor (or by the domain service that commits the change, in an app with no event bus); the socket
128
129
  library and the pub/sub engine (`socket.io`, `@socket.io/postgres-adapter`, `@socket.io/redis-adapter`, `graphql-subscriptions`, `graphql-ws`, `pg` for LISTEN and `ioredis`) are imported only by `platform/realtime`,
129
130
  and so is the heartbeat timer (`setInterval` is owned by `platform/realtime`, `infraOwners`). Because the door reads only the
130
- hub, a snapshot cannot be sent on connect from the door: the client reads the current state through a query operation first
131
- (or again on every open), and the stream carries the changes.
131
+ hub, a snapshot cannot be sent on connect from the door: the client reads the current state through a query operation that
132
+ checks resource membership first (or again on every open), and the stream carries the changes (BE-REALTIME-3).
132
133
  rationale: |-
133
134
  A door that can only touch the hub cannot change business state, so the biggest risk of a long-lived unauthenticated-looking
134
135
  connection, a write through it, is removed by type.
@@ -143,13 +144,26 @@ rules:
143
144
  - id: BE-REALTIME-3
144
145
  title: The topic is built from the principal
145
146
  kind: mandatory
146
- hfsRules: [R186, R41]
147
+ hfsRules: [R186, R41, R89, R204]
147
148
  requirement: |-
148
149
  Every `@SubscribeMessage`, `@Subscription` and `@Sse` handler takes `@CurrentPrincipal()` of `domain/identity` and builds the topic it
149
150
  passes to `hub.subscribe` from the principal's id (plus a client-supplied id only inside that prefix) with the topic
150
- builder of the owning domain contract, so one client cannot
151
- listen to another's channel. The reactor that pushes uses the same topic builder from the owning domain contract, never a
152
- hand-written string. The default-deny guards apply to the door like to any other; an SSE stream is never `@Public`. The
151
+ builder of the owning domain contract, so one client cannot listen to another's channel. A typed realtime topic builder is
152
+ a policy: it lives in `<name>.policy.ts` of slot `be.domain`, is exported through the capability's `index.ts`, and returns
153
+ `RealtimeTopic<Frame>` with the principal-prefixed `name` and the frame's narrowing `accepts` predicate. The named frame
154
+ contract lives in `*.contracts.ts`. The builder and predicate are pure functions of their inputs, with no injection, database
155
+ read or membership lookup; `policy` keeps the logic-role coverage of R204. The publisher and every door import this one
156
+ builder from the owning domain contract, never a hand-written topic string.
157
+
158
+ Resource membership is enforced on the publish side. The domain service that owns the change, or the domain service called
159
+ by a reactor's command, reads the current entitled members and fans out to one principal-prefixed topic per member. A client-supplied room
160
+ or conversation id stays inside that principal's prefix: a non-member can open only its own idle topic, which no publisher
161
+ targets. A removed member's already-open stream stays open but receives no newly published frames from the next publish onward, because
162
+ the refreshed member list no longer targets it. Frames already delivered or retained in its own topic are not revoked;
163
+ replay remains subject to the hub's bounds. Snapshot and history queries independently check current membership before
164
+ returning rows; the door injects only the hub (BE-REALTIME-2), never a membership-reading service or guard.
165
+
166
+ The default-deny guards apply to the door like to any other; an SSE stream is never `@Public`. The
153
167
  browser `EventSource` cannot send an `Authorization` header, so the front end reads the stream with `fetch` and the bearer
154
168
  access token (an operation of modules/api/sse read by a useSubscribe<Channel>Swr hook); a token in the URL is refused because it ends up in logs. The `Last-Event-ID` header is client
155
169
  input like any other: it is passed as `{ after }` to `hub.subscribe` and only picks where the replay resumes inside the
@@ -160,12 +174,25 @@ rules:
160
174
  - id: case-1
161
175
  when: Subscribing to one order's status
162
176
  write: 'hub.subscribe(orderStatusTopic(principal.id, orderId)); the reactor publishes to orderStatusTopic(buyerId, orderId) of the order owner, so a different principal receives nothing.'
177
+ - id: case-2
178
+ when: A room or conversation has several members and membership can change
179
+ write: |-
180
+ The owning domain exports roomMessageTopic(memberId, roomId) from its room-message.policy.ts, returning
181
+ { name: `room-message:${memberId}:${roomId}`, accepts: isRoomMessageFrame } with the declared return type RealtimeTopic<RoomMessageFrame>.
182
+ The SSE door calls hub.stream(roomMessageTopic(principal.id, roomId), { after: lastEventId }) and injects only the hub.
183
+ The publisher reads the current members and publishes a frame to roomMessageTopic(member.id, roomId) for each of them,
184
+ following the hub's commit ordering (BE-REALTIME-7). A non-member's topic receives nothing; removing a member leaves its
185
+ stream open and excludes it from subsequent fan-out. The snapshot query checks membership in its own read.
163
186
  verification:
164
187
  automated:
165
188
  - BE_REALTIME_TOPIC_SCOPE
166
189
  - BE_DEFAULT_DENY
190
+ - BE_SOURCE_FORM
191
+ - HFS_COVERAGE_SCOPE_DRIFT
167
192
  manual:
168
- - Review that the topic builder shared with the reactor lives in the domain contract and is used by both sides.
193
+ - Review that the pure topic policy is exported by the owning domain and used by both the publisher and door.
194
+ - Prove the publisher targets exactly the current member list, including the next publish after removing a member; the non-member's stream receives no frame.
195
+ - Prove snapshot and history queries reject a caller whose membership is absent or removed.
169
196
  relatedExamples: [realtime]
170
197
  - id: BE-REALTIME-4
171
198
  title: Three behaviours are proven on the test world
@@ -301,7 +328,7 @@ rules:
301
328
  hfsRules: [R90, R266, R155]
302
329
  requirement: |-
303
330
  Both editions run N api replicas. `platform/realtime` keeps ONE port, `RealtimeHub` (`publish(topic, frame, within?): Promise<void>`
304
- called by a reactor, or by the domain service that owns the state change and passes its own transaction as `within`,
331
+ called by a reactor, or by the domain service that owns the state change with the commit ordering below,
305
332
  `subscribe(topic, { after })` for GraphQL and socket doors, `stream(topic, { after })` for the SSE door), so a door never
306
333
  changes between a single replica and N replicas: only the hub binding does. Three adapters implement the port:
307
334
  `MemoryRealtimeHub` (non-scale: one process, tests and a single-replica dev run, replay from an in-process ring buffer),
@@ -318,8 +345,16 @@ rules:
318
345
  replay reads the stream with XRANGE and resumes after the newest entry carrying the id; when the subscriber connection comes
319
346
  back the hub reloads each followed topic after the minimum last stream id of its open subscriptions, with the same per-subscription
320
347
  duplicate suppression and paused retry policy as Postgres; ioredis, every key and channel carries the
321
- app prefix `REALTIME_REDIS_PREFIX`). The app selects the hub with `REALTIME_HUB=memory|postgres|redis`, parsed once by
322
- `parseRealtimeConfig` and validated at boot; unset, the parser defaults to `redis` when `REALTIME_REDIS_URL` is declared (lite
348
+ app prefix `REALTIME_REDIS_PREFIX`). Only the Postgres hub can publish inside the domain transaction: pass that
349
+ transaction as `within` when the domain write and realtime table use the same database. Redis and memory push immediately,
350
+ even if a caller passes `within`; they do not defer delivery until commit. With either hub the domain service awaits its
351
+ successful transaction before calling `hub.publish(topic, frame)` without `within`. A rollback therefore publishes nothing;
352
+ a publish failure after commit cannot roll back the domain write, and the client resyncs through its membership-checked
353
+ snapshot query. A service whose hub binding can vary uses this after-commit sequence unless it is bound to Postgres.
354
+ The app selects the hub with `REALTIME_HUB=memory|postgres|redis`, parsed once by
355
+ `parseRealtimeConfig` in `platform/realtime/realtime-config.mapper.ts` (the unit-tested mapping role of BE-NAMING-1),
356
+ with its unit spec in `realtime-config.mapper.spec.ts`, and validated at boot; unset, the parser defaults to `redis`
357
+ when `REALTIME_REDIS_URL` is declared (lite
323
358
  and full), `postgres` when lite declares no Redis, and never `memory`, so a scaled deploy cannot fall back to the in-process
324
359
  hub unseen. All three
325
360
  adapters are legal in both editions; select `redis` when Redis is declared (lite with Redis, and full), `postgres` when it is
@@ -381,6 +416,18 @@ rules:
381
416
  RealtimeModule.register({ isGlobal: true, ...withConnection(options.realtime, ORDER_ENTITY_MANAGER, options.database.url) })
382
417
  // REALTIME_HUB=redis, the secret REALTIME_REDIS_URL, REALTIME_REDIS_DB (and REALTIME_REDIS_PREFIX when the server is shared);
383
418
  // Redis 7 or later for the sharded socket.io adapter.
419
+ - id: case-9
420
+ when: A domain service changes state with the Redis or memory hub, or a selectable hub binding
421
+ write: |-
422
+ Collect the frame and entitled recipients while committing the change; publish only after the awaited transaction
423
+ resolves. No publish runs inside its callback, so a rollback sends no frame:
424
+ const push = await this.entityManager.transaction(async (tx) => {
425
+ // Read current membership and write the domain change through tx; return its topic/frame pairs.
426
+ return pendingPushes
427
+ })
428
+ for (const { topic, frame } of push) await this.hub.publish(topic, frame)
429
+ Passing tx to the Redis or memory hub does not make its immediate push transactional. Postgres alone may use
430
+ hub.publish(topic, frame, tx) inside that transaction (case-2).
384
431
  - id: case-4
385
432
  when: GraphQL subscription, non-scale
386
433
  write: |-
@@ -449,8 +496,14 @@ rules:
449
496
  manual:
450
497
  - Confirm the ingress holds no sticky-session declaration for the api, and that polling is off in the socket.io client configuration.
451
498
  - Confirm a Postgres hub connects through a session-mode URL and that the realtime tables are migrations of the app.
499
+ - Prove a domain rollback publishes nothing; with Redis or memory, verify publish follows the awaited commit and a publish failure leaves the committed change intact.
452
500
  relatedExamples: [realtime]
453
501
  files:
502
+ - path: src/modules/domain/<capability>/<name>.policy.ts
503
+ slot: be.domain
504
+ role: The pure typed topic builder and frame predicate of BE-REALTIME-3, exported by the domain contract for publisher and door.
505
+ spec: src/modules/domain/<capability>/<name>.policy.spec.ts
506
+ optional: true
454
507
  - path: src/features/realtime/<channel>/index.ts
455
508
  slot: be.feature.realtime
456
509
  template: realtime/channel.index.ts.tpl
@@ -544,6 +597,12 @@ files:
544
597
  slot: be.platform
545
598
  role: RealtimeHub, the one port of the realtime kind (publish, subscribe, stream) and the typed topic; its three adapters are the files below.
546
599
  optional: true
600
+ - path: src/modules/platform/realtime/realtime-config.mapper.ts
601
+ slot: be.platform
602
+ template: realtime/realtime-config.mapper.ts.tpl
603
+ role: parseRealtimeConfig maps EnvSource to the hub options at boot; hub-selection logic is a covered mapper role.
604
+ spec: src/modules/platform/realtime/realtime-config.mapper.spec.ts
605
+ optional: true
547
606
  - path: src/modules/platform/realtime/memory-realtime-hub.service.ts
548
607
  slot: be.platform
549
608
  role: "MemoryRealtimeHub, the non-scale adapter: one process, tests and a single-replica dev run, replay from an in-process ring buffer."
@@ -2348,6 +2348,16 @@ FE_DESTRUCTIVE_CONFIRM_IN_DIALOG:
2348
2348
  owner: op-retry
2349
2349
  kind: check-finding
2350
2350
 
2351
+ FE_DIVIDER_FULL_BLEED:
2352
+ title: "Horizontal Grammar divider is inset by wrapper padding"
2353
+ title_vi: "Divider bị padding ngang của wrapper làm thụt vào"
2354
+ meaning_vi: "Divider ngang của Grammar phải dài full và sát viền surface."
2355
+ causes_vi:
2356
+ - "Wrapper chứa Divider có padding ngang"
2357
+ nextStep_vi: "Chuyển padding vào các band nội dung cạnh Divider."
2358
+ owner: "op-retry"
2359
+ kind: "check-finding"
2360
+
2351
2361
  FE_EFFECT_CLEANUP:
2352
2362
  title: "What an effect starts, its cleanup releases"
2353
2363
  title_vi: "Effect khởi động thứ không được dọn"
@@ -0,0 +1,5 @@
1
+ // commit-tree.mjs — `git commit-tree <args>`: a commit object written from a tree (its sha on stdout).
2
+ import { runGit } from './lib.mjs';
3
+
4
+ /** The spawn result {status, stdout, stderr, error} of `git commit-tree ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
5
+ export const commitTree = (args = [], options = {}) => runGit(['commit-tree', ...args], options);
@@ -0,0 +1,8 @@
1
+ // update-ref.mjs — `git update-ref [-m <message>] <ref> <sha> [<old>]`: point a ref at a commit (a preserved worktree's
2
+ // refs/heads/preserved/<name>); with `old`, a compare-and-swap that fails when the ref no longer names `old`.
3
+ import { gitRunner } from './lib.mjs';
4
+
5
+ /** {ok, stdout, stderr}. message: the reflog line; old: the sha the ref must still name. git: the caller's runner or null. */
6
+ export function updateRef(repoRoot, ref, sha, { message = null, old = null, git = null } = {}) {
7
+ return gitRunner(git)(['update-ref', ...(message ? ['-m', message] : []), ref, sha, ...(old ? [old] : [])], { cwd: repoRoot });
8
+ }
@@ -0,0 +1,5 @@
1
+ // write-tree.mjs — `git write-tree <args>`: a tree object written from the index (its sha on stdout).
2
+ import { runGit } from './lib.mjs';
3
+
4
+ /** The spawn result {status, stdout, stderr, error} of `git write-tree ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
5
+ export const writeTree = (args = [], options = {}) => runGit(['write-tree', ...args], options);