@starci/hfs 4.10.1 → 4.11.8

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +40 -1
  3. package/db/drift.mjs +19 -16
  4. package/db/image.mjs +13 -0
  5. package/db/migrate.mjs +3 -3
  6. package/db/postgres.mjs +1 -1
  7. package/package.json +2 -1
  8. package/runtime/knowledge/hfs/canon-pins.yaml +23 -8
  9. package/runtime/knowledge/hfs/rules.yaml +54 -13
  10. package/runtime/knowledge/hfs/slots.yaml +67 -36
  11. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  12. package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
  13. package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
  14. package/runtime/knowledge/patterns/be/queues.yaml +14 -14
  15. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  16. package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
  17. package/runtime/modules/kernel/failure-codes.yaml +28 -18
  18. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  19. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  20. package/runtime/scripts/guards/push-scan.mjs +1 -1
  21. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  22. package/runtime/scripts/hfs/db-baseline-verb.mjs +94 -22
  23. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  24. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  25. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  26. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  27. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  28. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  29. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  30. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  31. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  32. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  33. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  34. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  35. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  36. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  40. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  41. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  42. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  44. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  45. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +3 -4
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  47. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  48. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  49. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  50. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  52. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  53. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  54. package/runtime/scripts/hfs/secret-sops.mjs +93 -9
  55. package/runtime/scripts/hfs/secret-standard.mjs +315 -0
  56. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  57. package/runtime/scripts/hfs/secret.mjs +110 -46
  58. package/runtime/scripts/hfs/slots.mjs +8 -1
  59. package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
  60. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  61. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  62. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  63. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  64. package/runtime/scripts/lib/swarm-stack.mjs +4 -6
  65. package/scaffold/add-inbox-lite.mjs +2 -2
  66. package/scaffold/add-queue-lite.mjs +24 -5
  67. package/scaffold/app.mjs +2 -2
  68. package/src/main.mjs +5 -4
  69. package/stack/build.mjs +3 -1
  70. package/stack/config.mjs +2 -1
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +4 -3
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/target-binding.mjs +2 -1
  75. package/sync/hygiene.mjs +1 -1
  76. package/sync/index.mjs +47 -12
  77. package/templates/app/db-bootstrap/db/bootstrap.sql +9 -14
  78. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  79. package/templates/app/gitignore +2 -1
  80. package/templates/app/gitignore-lite +3 -1
  81. package/templates/app/package-scripts/db.json +2 -1
  82. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
  83. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  84. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
  85. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -1
  87. package/templates/app/skeleton-lite/README.md +2 -2
  88. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  89. package/templates/be/inbox/events.json.tpl +1 -1
  90. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  91. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  92. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  93. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  94. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  95. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  96. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  97. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  98. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  99. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  100. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  101. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  102. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  103. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  104. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  105. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  106. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  107. package/templates/be/patterns/jobs/platform/jobs.log-events.ts.tpl +2 -0
  108. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  109. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  110. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +65 -0
  111. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
  112. package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
  113. package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
  114. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
  115. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  118. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  119. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  120. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  121. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  122. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
  123. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
  124. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  125. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  126. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  127. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  128. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  129. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  130. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  131. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  132. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  133. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.11.8 - 2026-10-29
4
+
5
+ - Added: `starci app secret migrate <env> <member> --to <existing> --into-existing` and `split <env> <member> --into-existing` merge into a member that already exists (never a legacy `.enc` one: migrate it first). Without the flag an existing target is refused, naming the flag. Every existing target is decrypted in memory and checked before anything is written: a key it already holds with another value refuses the whole move (`refused, nothing was written: <env>/<member>.enc.yaml already holds KEY with another value`, names only); an identical value merges. Each target is written once, and the source is removed only after every target is written (an own target is staged as `<member>-staged.enc.yaml` and moved over it). The dry run names each target's keys, which targets exist and the keys they already hold (`secret-standard.mjs` `plannedTargets`, `moveKeys`).
6
+ - Added: `split <env> <member> --group KEY=member` (repeatable) and `--group-file <yaml>` (a map of `KEY: member`) send a key to the owner's member instead of its first word's; a key named twice, or one the member does not hold, is refused. The dry run prints the resulting plan (`groupMap`).
7
+ - Changed: `audit` does not name MIXED_PROVIDERS for a member the stack groups on purpose, one whose every key the environment's `secrets:` declares per key with `member: <that member>` (`declaredGroup`); the finding names that fix beside split.
8
+ - Changed: `deploy.secrets.settings` of application-stacks takes one `{ member, keys }` or a list of them over several members; a key two entries name is refused (STACKS_DEPLOY_CONFIG_INVALID), and the sealer reads each listed member once (`stack-deploy-config.mjs`, `stack/sealed.mjs`).
9
+ - Changed: the bundled canon pins name hfs 4.11.8, eslint-canon-be 3.8.7, eslint-canon-fe 9.5.9 and the Grammar family 0.15.20.
10
+ - Spec: `src/hfs-secret-standard.spec.mjs` (group, merge, collision refusal, group file, grouped audit; fake values only), `src/hfs-stacks-deploy.spec.mjs` (settings list).
11
+
12
+ ## 4.11.7 - 2026-10-29
13
+
14
+ - Fixed: `starci app db baseline --squash` reaches a loopback source (`127.0.0.1`, `localhost`, `[::1]`, as the app's own dev database on `127.0.0.1:54322`) on a Linux Docker engine. The scratch container reached it only as `host.docker.internal`, which a Linux engine maps to its bridge gateway, where a port published on 127.0.0.1 refuses the connection; Docker Desktop alone forwarded it. `sourceTarget` (`runtime/scripts/hfs/db-squash.mjs`) now keeps the first route psql connects on: the scratch container through `host.docker.internal`, else a one-shot container of the same image on the host's network; when neither connects the verb refuses with each route's psql failure. Every client tool runs where its target's route says (`exec`, `targetRunner`).
15
+ - Changed: a failed history read of the squash source names the psql failure (password and URL withheld) instead of "database command failed; inspect psql privately".
16
+ - Changed: `starci app db plan` refuses a dynamic migration block naming every `EXECUTE` of every active migration as `<file>:<line>` under "dynamic migration blocks cannot prove database inventory", instead of the bare heading at the first one (`repositoryState`, `runtime/scripts/hfs/db-plan-verb.mjs`; the line is the DO body's opening quote plus the statement's body line).
17
+ - Changed: `starci app db baseline` passes the plan's refusal through (`cleanPlan`, `db-baseline-verb.mjs`) after "baseline requires a clean database plan with every migration already applied; the plan refused: ", and on drift names each inventory that differs with its missing, unexpected and changed counts, instead of the bare heading with the plan's output discarded.
18
+ - Changed: the bundled canon pins name hfs 4.11.7, eslint-canon-be 3.8.6, eslint-canon-fe 9.5.8 and the Grammar family 0.15.19.
19
+ - Spec: `src/hfs-db-squash.spec.mjs`: its source, published on 127.0.0.1 only, is the loopback route CI's Linux engine proves; a source no route reaches is refused. `src/hfs-database-baseline.spec.mjs`: two EXECUTEs in one block after a multi-byte comment and one in a second migration are named by file and line, with no database query; baseline's pass-through of a plan refusal and of a drift.
20
+
21
+ ## 4.11.6 - 2026-10-29
22
+
23
+ - Added: the skeleton's `platform/http` takes `HttpOptions.trustedCa`, the PEM of one more certificate authority the outbound client trusts beside Node's bundled roots (never instead of them; verification stays on). `FetchHttpClient` builds one undici `Agent({ connect: { ca: [...rootCertificates, trustedCa] } })` for it and hands it to the global `fetch` as `dispatcher` on every request, closing it at module destroy; without the option it builds no pool and every request is sent exactly as before. `HTTP_OPTIONS` and `InjectHttpOptions` carry the options (`http.decorators.ts`), `parseHttpConfig` (`http.config.ts`) reads them from the optional `HTTP_TRUSTED_CA` (or `HTTP_TRUSTED_CA_FILE`), and the full skeleton's api app reads `http: parseHttpConfig(env)` into its options and registers `HttpModule.register({ isGlobal: true, ...options.http })`, so a test world passes its per-run fake CA (`w.fake.<name>.ca`) as `HTTP_TRUSTED_CA` in the environment it builds the app's options from.
24
+ - Added: `undici` 6.29.0 joins the starter dependencies of both editions (its one owner is `platform/http`, R90). Specs: `src/platform-http-trusted-ca.spec.mjs` (the skeleton client against a real HTTPS server signed by a per-run CA from test-world's `issueFakeTls`: refused without `trustedCa`, answered with it, refused under an unrelated CA, and the CA joins the bundled roots), and the skeleton's `fetch-http-client.client.spec.ts` (no dispatcher by default; one shared pool with the CA, closed at destroy).
25
+ - Changed: the bundled canon pins name hfs 4.11.6, @starci/test-world 1.3.1, eslint-canon-be 3.8.5, eslint-canon-fe 9.5.7 and the Grammar family 0.15.18, and pin `undici` 6.29.0 (owner `platform/http`).
26
+
27
+ ## 4.11.5 - 2026-10-29
28
+
29
+ - Changed: `starci app secret set <env> <member> <KEY> [KEY ...]` seals every named key in one sops write, so no member is left half filled. On a terminal it prompts `<KEY>: ` for each key with masked input (`runtime/scripts/hfs/secret-prompt.mjs` `promptHidden`: one `*` per character, a paste in one chunk, backspace, Enter; Ctrl+C or end of input cancels with nothing written; an empty value is refused) instead of refusing "set reads a value from redirected stdin"; redirected stdin gives one key its whole text (a multi-line value kept) or several one `KEY=value` line each, every key once and no other; `--generate <bytes>` makes one value per key. `secretMain` is async and takes `prompt` and `hostIdentity` ports, which `main` passes through.
30
+ - Changed: the age identity is found by itself (`localAgeIdentity`, `secret-init.mjs`): SOPS_AGE_KEY or SOPS_AGE_KEY_FILE of the environment, else of the app's `.claude/secret.env`, else of its main checkout's when the app is a git worktree (`mainCheckout`, `secret-files.mjs`), else the `hostIdentity` the harness CLI passes (its secret.env's SOPS_AGE_KEY), else the sops `keys.txt`. `defaultSops` takes that port and exposes `identity` and `requireIdentity`; a `set` that adds to an existing member, and any decrypt, with no identity is refused before a value is asked for, naming the recipient, every place looked and the one-line fix. A decrypt whose identity is not the member's recipient is refused naming where the identity came from and the recipient, instead of `SOPS selected identity [recipient-mismatch]`. Re-sealing a member whose every key the set replaces decrypts nothing (`secret-store.mjs`). The success line of a set that creates a member names its recipient and where this machine holds the identity, or the fix.
31
+ - Added (ruling app-secrets-one-standard): a member is one provider's `.starcistacks/<env>/secrets/<provider>.enc.yaml`, sops YAML whose values come back byte for byte (decrypted to JSON, `secret-sops.mjs`); a legacy dotenv `.enc` is read, and added to in its own format, until `migrate` moves it. Every reader of the secrets folder resolves members through `runtime/scripts/lib/sealed-member.mjs` (`memberFile`, `isSealedName`; a member sealed in both forms is refused), and the gates accept `.enc.yaml` where they accepted `.enc` (stacks shape, custody-only, lite secrets, env parity, R06, hygiene, the push scan, the stacks gate's `where`, deploy and script scans); the `.starcistacks` gate's custody check admits `<env>/secrets/<member>.enc.yaml` while runtime custody keeps `<name>.<fmt>.enc`.
32
+ - Added: `set --from-file KEY=path` reads a key byte for byte (a PEM, a trailing newline); a member is sealed to its environment's rule of the app's `.sops.yaml`, and sealing runs with no identity selected, so a rule may name several recipients. `recipients <env> [--add|--remove <age1...|owner>]` prints or rewrites one rule per environment before the app-wide one (`environmentRecipients`), `owner` being the found identity's recipient; `init` takes that identity (the harness secret.env's included) as the app's recipient instead of generating one. The app-wide rule matches `.enc.yaml`, and an earlier `.enc`-only rule still matches a `.enc.yaml` member.
33
+ - Added: `audit [<env>]` names LEGACY_MEMBER, MIXED_PROVIDERS (by the first word of the keys), RECIPIENT_DRIFT, UNDECLARED_KEY, MISSING and UNTRACKED, exit 1 while any stands; `migrate <env> <member> [--to <provider>] [--execute]` and `split <env> <member> [--execute]` move members, dry runs naming keys only, decrypting in memory and never writing plaintext (`secret-standard.mjs`). `set` declares each key of a compose environment in its stack `secrets:` (`name`, `member`, `key`, `where`, comments kept, the edit proved by a re-parse); a swarm environment's keys are named to wire by hand. The application-stacks schema admits `member` and `key` on a secret entry, and the stacks gate checks the member holds the key.
34
+ - Changed: the managed `.gitignore` block re-includes `.starcistacks/**/secrets/*.enc.yaml` beside the legacy `*.enc`.
35
+ - Spec: `src/hfs-secret-standard.spec.mjs`.
36
+ - Changed: the bundled canon pins name hfs 4.11.5, eslint-canon-be 3.8.4, eslint-canon-fe 9.5.6 and the Grammar family 0.15.17.
37
+ - Spec: `src/hfs-secret-set.spec.mjs` (fake values only; a child process with a forced-TTY stdin drives the real prompt).
38
+
39
+ ## 4.11.4 - 2026-10-29
40
+
41
+ - Changed: the bundled canon pins name @starci/test-world 1.3.0 (HTTPS fakes over the run's ephemeral fake CA), hfs 4.11.4, eslint-canon-be 3.8.3, eslint-canon-fe 9.5.5 and the Grammar family 0.15.16. No rule changed.
42
+
43
+ ## 4.11.3 - 2026-10-29
44
+
45
+ Breaking for lite apps (rulings lite-pgmq-bus-bullmq-jobs and lite-db-image-in-stacks): `npx starci app sync --write` moves the db image and removes `db/Dockerfile`; an app whose hfs.json declares `sides.be.queueOutbox` drops it, and a pg_cron job left in an active migration is rewritten as a `platform/jobs` tick.
46
+
47
+ - Changed: the managed db image installs pgmq, the lite event bus: `ADD --checksum=sha256:67221f28…27f7` of the pgmq 1.13.1 release source (sync `PGMQ`), whose control and SQL files are copied into the image (pgmq is SQL objects only; it installs into its own schema `pgmq`). Proved by building the image and running create, send_topic, read, archive, bury and requeue as the app role.
48
+ - Changed: the db image's Dockerfile is `.starcistacks/images/db/Dockerfile` (slot `app.db.image`, `db/image.mjs` `DB_DOCKERFILE` and `DB_IMAGE_CONTEXT`), built with that folder as its context by `stack build`, the managed `images.yml` and `stack-deploy.yml`, `npm run db:image`, `app db drift` (`DB_DRIFT_NO_IMAGE` names the new path) and `app db baseline --squash`; `app sync` reports a leftover `db/Dockerfile` as retired (`--check`) and removes it (`--write`). The lite .gitignore block re-includes the file, and the stacks gates treat `.starcistacks/images/` as no environment and no custody.
49
+ - Changed: the lite event-bus templates (templates/be/inbox/platform/event-bus, contexts/code/be/event-bus.md BE-EVENT-BUS-6) publish once with `public.event_bus_send` (`pgmq.send_topic` of the typed envelope to the event name) in the publisher's transaction, with no outbox table or relay, and pgmq routes it to every consumer queue `<topic>__<consumer>` bound to the name. `event-consumer.service.ts` is the `EventConsumerRegistry` (`subscribe(QUEUE, consumer, { maxDeliveries })`) and the read loop of each consumer queue (started at application bootstrap, stopped at shutdown, no scheduler): `public.event_bus_read` with a visibility timeout, `public.event_bus_archive` on success, a failure left to reappear, `public.event_bus_bury` into `<queue>__dead` (original archived, logged with event and consumer) past `maxDeliveries` (default 5) or when malformed. `BaseEvent.payload` is an `EventPayload` carrying `dedupeKey`, and each delivery hands the consumer `consumer` and `dedupeKey` for its inbox claim; `EventBus` gains `deadLetters(queue, limit)` and `requeue(queue, id)`. The receipt event and its contract carry `dedupeKey` (the receipt id). The inbox migration creates the extension and the security-definer bus functions (the app role holds no pgmq grant); the `event_bus` table and `event_bus_write` are retired. Proved on the built image: a rolled-back send leaves nothing, a committed send reaches two bound consumers once each, bury, dead-letter read and requeue.
50
+ - Changed: R258 `DB_PGMQ_DECLARED` (`rules/database-queues.mjs`) stays the event-bus law, requires each topic queue's `<queue>__dead` queue (which it owns), and also refuses a `pgmq.bind_topic` that is not literal, not in an active migration, or binds a queue no earlier `pgmq.create` declared; `DB_CRON_OWNED`, the queue-outbox enqueue check, `rules/database-queue-outbox.mjs` and the scheduler inventory of `rules/database-queue-sources.mjs` are retired (pg_cron stays refused by R259). `databaseQueueDeclarations` still lists the pg_cron jobs an archived chain schedules for `db baseline`.
51
+ - Changed: the bundled slot manifest 3.13.0 admits `pgmq` in `ruleParams.db.extensions` (the lite event bus, ruling lite-pgmq-bus-bullmq-jobs), retires `ruleParams.db.queueOutbox` and `sides.be.queueOutbox` with the pg_cron half of R258, lists `public.event_bus_send`, `public.event_bus_read`, `public.event_bus_archive`, `public.event_bus_bury`, `public.event_bus_dead_letters` and `public.event_bus_requeue` in place of `public.event_bus_write` in `ruleParams.be.dbFunctions`, and moves slot `app.db.image` to `.starcistacks/images/db/Dockerfile` (ruling lite-db-image-in-stacks, admitted by the lite `app.starcistacks` allows); the bundled rule catalog keeps R258 `DB_PGMQ_DECLARED` as the event-bus law, retires `DB_CRON_OWNED` and adds R336 `BE_EVENT_BUS_LITE_SHAPE`; the bundled canon pins name hfs 4.11.3, eslint-canon-be 3.8.2, eslint-canon-fe 9.5.4 and the Grammar family 0.15.15.
52
+
53
+ ## 4.11.2 - 2026-10-29
54
+
55
+ - Changed: the lite skeleton's database helpers live in `platform/database/database.client.ts` (a `client`, a logic role of the closed suffix list, so the file is measured and its colocated `database.client.spec.ts` is a legal unit spec), replacing `database.functions.ts`, whose suffix is not on the list. `callDbFunction` takes `schema.name` or a bare pg built-in of the canon's list (called in pg_catalog), refuses anything else with `DatabaseError` `DATABASE_IDENTIFIER_REJECTED` before building SQL, double-quotes each checked part and builds the parameter list from the argument count; it is no longer `async` (R73). The call statement parses for R268's recurring-work scan, so it raises no `HFS_SCHEDULE_UNDECLARED`.
56
+ - Changed: the lite jobs (`job_tick_claim` of R335 among them), queue, inbox and event-bus templates call their database functions schema-qualified (`public.job_claim`, `public.queue_write`, ...), and their specs expect the quoted statement. Spec: `src/db-functions-call.spec.mjs`.
57
+ - Changed: the bundled slot manifest 3.12.0 lists the canon's database functions of R334 schema-qualified (`public.job_claim`, `public.job_tick_claim`) beside the pg built-ins it names bare (`gen_random_uuid` joins them), and its shape (`rule-params-shape.mjs`, `declaration-shape.mjs`) with the manifest check of `slots.mjs` refuses in hfs.json `sides.be.dbFunctions` a `pg_catalog` name and any bare name that is not a built-in the canon lists; the manifest bump is minor although that refusal is stricter, since R334 is new on manifest major 3 and its one app (nivo-lite) lists only names it admits (its one bare name, `gen_random_uuid`, is now a canon built-in); the bundled rule catalog's R334 and R68 laws name `platform/database/database.client.ts` as the one composer of the call statement; the bundled canon pins name hfs 4.11.2, eslint-canon-be 3.8.1, eslint-canon-fe 9.5.3 and the Grammar family 0.15.14.
58
+
59
+ ## 4.11.1 - 2026-10-29
60
+
61
+ - Added: `starci app db baseline --squash --url-env <NAME>` (`runtime/scripts/hfs/db-squash.mjs`) retires the whole active chain into one baseline read from a database whose history records exactly that chain, for a cutover whose production starts on an empty database; the source is only read. It keeps the app's schemas with their privileges (`pg_dump --schema-only --no-owner`, no `-x`) and object references and function bodies byte-identical, and leaves out, listing each, the extensions of `--drop-extension` (default `pg_net,pg_cron`; pgmq stays as the lite event bus), the pgmq queues of `--drop-pgmq-queue` (names or `*` patterns, no match drops nothing), every function or view reaching a dropped extension's schema, an empty publication (`supabase_realtime`), event triggers on platform functions, platform schemas, roles and the grants naming them, and grants to the migrating role that owns every replayed object; a kept object naming a left-out one is refused with both names. `--apply` writes the baseline (kept pgmq queues and grants outside the app schemas in its tail), a marked section of `db/bootstrap.sql` (app roles with their attributes and no password, memberships, allowed role settings such as `search_path`, grants on bootstrap schemas, database-wide default privileges), `db/seed.sql` from `--seed-tables` (`on conflict do nothing`, foreign-key order, sequences set) and archives the chain. Each run first replays bootstrap, baseline and seed (twice) on a scratch container of the app's db image (`db/Dockerfile`, or `--image`) and refuses unless the replay's projection equals the source's; every PostgreSQL client runs in that container. Spec: `src/hfs-db-squash.spec.mjs`.
62
+ - Changed: `db/bootstrap.sql` creates the `extensions` schema again, with USAGE for its roles: allowed extensions are created `with schema extensions` and the app role's search_path is `public, extensions`, so migrations that name `extensions.vector`, `extensions.unaccent` or `extensions.gen_random_bytes`, or pin a function's search_path to `''`, apply unchanged (removing the schema in 4.11.0 broke them). `starci app sync` judges the file except the one section the squash writes (sync mode `section`; the markers live in `rules/database-constants.mjs` with `BOOTSTRAP_FILE`, which `db/migrate.mjs` and `db/drift.mjs` re-export).
63
+ - Added: R213 (`DB_MIGRATION_SHAPE`) refuses transaction control in a migration, named by file:line (`rules/database-transaction.mjs`): a top-level BEGIN, START TRANSACTION, COMMIT, END, ROLLBACK, ABORT, SAVEPOINT, RELEASE or PREPARE TRANSACTION, or a COMMIT or ROLLBACK inside a DO block, since `db migrate` and the squash replay apply each file in one transaction with its history row; function and procedure bodies are not judged. Spec: `src/db-migration-transaction.spec.mjs`.
64
+ - Changed: the bundled canon pins name hfs 4.11.1.
65
+
66
+ ## 4.11.0 - 2026-10-29
67
+
68
+ Breaking for lite apps (rulings lite-official-postgres-image and lite-queue-bullmq; the move is contexts/project/lite.md "Moving an app off Supabase").
69
+
70
+ - Changed: the lite db service runs the Docker Official `postgres` image. `starci app sync --write` writes the managed `db/Dockerfile` (slot `app.db.image`, templates/app/db-image): `FROM postgres:17.11@sha256:31ba9c94…b08f` (sync `DB_BASE_IMAGE`) plus `postgresql-17-pgvector=0.8.7-1.pgdg13+1` from PGDG (`PGVECTOR_PACKAGE`), nothing else. The managed db scripts gain `db:image` (`docker build --tag <project>-db:local db`) and `db:create` builds it first; the lite skeleton's stack runs `{{project}}/db` for its db component and service, deploys it as `deploy.images.db: {app: db}` (`starci app stack build` and the managed `images.yml` and `stack-deploy.yml` build `db/Dockerfile` for it, `db/image.mjs`); `starci app db drift` builds `db/Dockerfile` for its scratch database (`DB_DRIFT_NO_IMAGE` without one) instead of reading `components.db.image`. `DB_IMAGE` (supabase/postgres) is removed.
71
+ - Changed: `db/bootstrap.sql` keeps the NOLOGIN browser roles and the auth and storage stubs and no longer creates the `extensions` schema or the `supabase_realtime` publication; it applies green, twice, on the official image.
72
+ - Changed: R276 refuses every PostgreSQL or Mongo image in a lite stack (`isLiteDatabaseImage` is removed): the db component runs the app's own db image; `DB_EXTENSION_ALLOWED` (R259) defaults to pgcrypto, pg_trgm, vector, uuid-ossp, unaccent and btree_gist.
73
+ - Added: the lite tick scheduler (R335, BE-JOBS-6). `starci app add job` in lite writes `platform/jobs/job-ticks.contracts.ts` (`JOB_TICKS`, empty), `job-tick.service.ts` (`JobTickService`: one `CronJob` per tick in `SchedulerRegistry`; each firing takes `advisoryXactLock(tx, "tick:<name>")`, claims the tick's UTC minute through `job_tick_claim` and only then writes the queue outbox) and its spec, a lite `jobs.module.ts` composing `ScheduleModule.forRoot()`, the `job_ticks` table and `job_tick_claim` function in the jobs migration, and pins `@nestjs/schedule` and `cron` in the app's package.json; `JobsLogEvent.TickFailed`; `job_tick_claim` joins the canon `ruleParams.be.dbFunctions`. The bundled lite pattern knowledge (queues, jobs, idempotency, realtime) names the tick instead of pg_cron, and the failure-code catalog carries `BE_JOBS_SCHEDULER_OWNER`.
74
+ - Changed: the bundled slot manifest 3.11.0 adds the managed lite slot `app.db.image` (`db/Dockerfile`), narrows `ruleParams.db.extensions` to what the official postgres image plus pgvector carries (pgcrypto, pg_trgm, vector, uuid-ossp, unaccent, btree_gist; no pgmq, pg_cron, pg_net or supabase_vault) and gives `@nestjs/schedule` and `cron` one owner, `platform/jobs`, in `infraOwners`; the bundled rule catalog adds R335 `BE_JOBS_SCHEDULER_OWNER` and its R250 and R276 laws name the platform/jobs tick and the db image; the bundled canon pins name hfs 4.11.0, eslint-canon-be 3.8.0, eslint-canon-fe 9.5.2 and the Grammar family 0.15.13 and pin `@nestjs/schedule` 6.1.3 (CommonJS on the Nest 11 line) and `cron` 4.4.0.
75
+ - Fixed: `starci app db baseline` judges the archive (`db/migrations/archive/`) as frozen history (R257): an archived file needs only its leading 14-digit stamp and an underscore, so a retired chain's snake_case names, impossible times (`20261014300000`) and shared stamps no longer refuse the cutover. The active chain keeps R213 in full (`<ts14>_<kebab>.sql` with a valid UTC stamp); R213 and R332 set no upper bound, so an active stamp after now is no breach. A file both active and archived is refused by name. The new version sorts after every archived and active file, and the baseline header names the last valid stamp, the boundary R257 judges.
76
+ - Fixed: a refusal over the migration chain lists every offender as `<file>: <reason> (<rule>)`, all at once: a misnamed or invalid-time active file (`db baseline`, `db restamp`, `db plan`), and for `db baseline` the `--stamp` boundary file, the committed-history findings (R257), the queue and cron findings (R258) and each active migration a schema-only dump cannot rebuild. Spec: `src/hfs-database-baseline.spec.mjs`.
77
+
78
+
3
79
  ## 4.10.1 - 2026-10-29
4
80
 
5
81
  - Changed: the bundled slot manifest 3.10.0 adds the lite slot `be.platform.database.entities`, the entity registry (slot fields `role: entity-registry` and `importedBy`, validated by the manifest shape); the bundled architecture machine reads it (`architecture/entity-registry.mjs`): `tier-direction` (R26 `BE_TIER_DIRECTION`) admits the registry's import of a domain or platform capability's `index.ts` that binds only that capability's `<c>Entities`, and `owner-export-bypass` (R30 `ARCH_OWNER_EXPORT_BYPASS`) admits a direct import of the registry from the files `importedBy` names (`apps/*/src/compose/*.ts`, `apps/*/src/app.module.ts`, `src/tests/world/**`); every other import of either shape stays refused, and full has no registry; the bundled canon pins name hfs 4.10.1, eslint-canon-be 3.7.1, eslint-canon-fe 9.5.1 and the Grammar family 0.15.12; the lite skeleton's `platform/database/entities.ts` names its importers (app composition and the test world).
package/README.md CHANGED
@@ -24,7 +24,7 @@ starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
24
24
  starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
25
25
  starci app new spec <file>.service.ts [--cwd <dir>]
26
26
  starci app new image [--cwd <dir>]
27
- starci app secret list|show|set|gen ... [--cwd <dir>]
27
+ starci app secret init|set|get|unset|rename|remove|materialize|list|verify|recipients|migrate|split|audit ... [--cwd <dir>]
28
28
  starci app hygiene [--cwd <dir>]
29
29
  ```
30
30
 
@@ -92,6 +92,25 @@ and 2 is no verdict: a host without Docker or without the app's packages gets `D
92
92
  green. An app with an entity list runs it as the `db-drift` row of `starci app release gate` and in the database step of its managed CI's
93
93
  build job.
94
94
 
95
+ `starci app db baseline --squash --url-env <NAME> --stamp <ts14> [--drop-extension a,b] [--drop-pgmq-queue q1,job_*] [--seed-tables t1,t2]
96
+ [--image <ref>] [--apply]` retires the whole active chain into one baseline read from a database it is applied to, for a cutover whose
97
+ production starts on an empty database (`runtime/scripts/hfs/db-squash.mjs`). The source must record exactly the active chain in its
98
+ history, and it is only read. The verb dumps the source with its privileges (`pg_dump --schema-only --no-owner`, no `-x`) and keeps the
99
+ app's schemas: it leaves out the extensions `--drop-extension` names (default `pg_net,pg_cron`; pgmq stays, the lite event bus), the
100
+ pgmq queues `--drop-pgmq-queue` names (a name or a `*` pattern; no match drops nothing), every function or view whose body reaches a
101
+ dropped extension's schema, an empty publication, an event trigger on a platform function, the platform's schemas and roles and the
102
+ grants naming them, and grants to the migrating role that owns every replayed object; each is listed, and a kept object naming a left-out
103
+ one is refused with both names. Object references and function bodies stay byte-identical. It writes `db/migrations/<stamp>_baseline.sql`
104
+ (the `-- starci:baseline <last archived stamp>` header, the kept extensions created where the source has them, the schema and grants, then
105
+ the kept pgmq queues and the grants on objects outside the app schemas), a marked section of `db/bootstrap.sql` (the source's app roles
106
+ with their attributes and no password, their memberships, their allowed role settings such as `search_path`, the grants on schemas the
107
+ bootstrap creates and the database-wide default privileges, none of which a per-database dump keeps; `starci app sync` judges and keeps
108
+ the rest of the file), `db/seed.sql` (the rows of `--seed-tables`, `insert ... on conflict do nothing` in foreign-key order with their
109
+ sequences set; a table without a primary key or unique constraint is refused) and moves the chain to `db/migrations/archive/`. Before
110
+ writing, a scratch container of the app's db image (built from `.starcistacks/images/db/Dockerfile`, or `--image`) replays bootstrap, baseline and seed (the
111
+ seed twice); the same projection read from the replay must equal the source's, or the verb refuses with the differences. Docker runs every
112
+ PostgreSQL client, so the host needs none. Without `--apply` it writes nothing.
113
+
95
114
  Input bounds stay with the backend DTO: `MaxLength`, `ArrayMaxSize`, `Min` and `Max` become GraphQL `@inputBounds`
96
115
  input-field directives or JSON Schema constraints on the REST operation table's DTO properties. Codegen derives
97
116
  `INPUT_BOUNDS` beside GraphQL wire types and `REST_INPUT_BOUNDS` under the REST API owner's `__generated__/` directory.
@@ -198,6 +217,26 @@ pass.
198
217
  Exit codes: 0 clean, 1 an error finding, 2 a tool could not run or a refusal (not a Git work tree, bad flag, `--fast` with no merge-base). `starci app check` and `starci app lint` never write to the app
199
218
  (`starci app lint --fix` lets ESLint and stylelint fix in place).
200
219
 
220
+ ## Secrets
221
+
222
+ `starci app secret` keeps an app's sealed members (the standard is the harness's PROJECT-STACKS-5): one per provider,
223
+ `.starcistacks/<env>/secrets/<provider>.enc.yaml`, sops YAML whose values keep every byte, a legacy dotenv `<member>.enc`
224
+ read until `migrate` moves it (`runtime/scripts/lib/sealed-member.mjs` names both). `set <env> <provider> <KEY> [KEY ...]`
225
+ seals every named key in one sops write (`runtime/scripts/hfs/secret.mjs`): on a terminal it prompts `<KEY>: ` for each with
226
+ masked input (one `*` per character; a paste, backspace, Enter, Ctrl+C to cancel with nothing written), redirected stdin gives
227
+ one key its whole text or several one `KEY=value` line each, `--from-file KEY=path` reads a key byte for byte
228
+ (`secret-prompt.mjs`), and `--generate <bytes>` makes a random value per key. It then declares the keys in the environment's
229
+ `secrets:` of a compose environment (`declareKeys`, `secret-standard.mjs`) and names a swarm environment's to wire by hand.
230
+ A member is sealed to its environment's rule of the managed `.sops.yaml`; `recipients <env> [--add|--remove <age1...|owner>]`
231
+ rewrites that rule (`environmentRecipients`, `secret-init.mjs`), and `init` takes the owner's identity as the app's recipient.
232
+ Re-sealing a member whose every key the set replaces needs no age identity; adding a key to one does, and the identity is found
233
+ by itself (`localAgeIdentity`): SOPS_AGE_KEY or SOPS_AGE_KEY_FILE of the environment, else of the app's git-ignored
234
+ `.claude/secret.env`, else of its main checkout's when the app is a git worktree, else the `hostIdentity` the harness CLI passes
235
+ (its own secret.env), else the sops `keys.txt`; with none, the verb refuses before asking for a value, naming every place it
236
+ looked and the one line that fixes it. `audit [<env>]` names every deviation and exits 1; `migrate <env> <member> [--to
237
+ <provider>]` and `split <env> <member>` repair them, dry runs until `--execute` (`secret-standard.mjs`). No output carries a
238
+ value except `get --reveal`.
239
+
201
240
  ## Sonar
202
241
 
203
242
  One mechanism for both sides: every finding of the canon is imported into Sonar, and the quality gate
package/db/drift.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  // The migrations (db/migrations/*.sql, after db/bootstrap.sql when present) are the only DDL and `synchronize` is false, so an entity only
3
3
  // maps tables that already exist and can drift from them. The verb builds TypeORM's metadata from the app's entity list
4
4
  // (be/src/modules/platform/database/entities.ts, compiled in-process with the app's own typescript and loaded with the app's own typeorm) through a
5
- // DataSource that never connects, applies the migrations to a scratch database (a throwaway container of the image the app's
6
- // .starcistacks declares for its db component, or the empty database named by --url-env), reads that schema from
5
+ // DataSource that never connects, applies the migrations to a scratch database (a throwaway container of the image the app's managed
6
+ // .starcistacks/images/db/Dockerfile builds, or the empty database named by --url-env), reads that schema from
7
7
  // information_schema.columns and pg_catalog, and reports every entity column whose table or column is missing, whose type differs (both
8
8
  // normalised to PostgreSQL's format_type names) or whose nullability differs, under BE_ENTITY_SCHEMA_DRIFT. Database columns no entity maps
9
9
  // are fine. Exit 0 is green, 1 is drift (or a migration that does not apply to an empty database), 2 is a refusal: no verdict, which is
@@ -12,14 +12,17 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import Module, { createRequire } from 'node:module';
14
14
  import { randomBytes } from 'node:crypto';
15
- import { runVerb, readDeclaration } from '../release/index.mjs';
15
+ import { runVerb } from '../release/index.mjs';
16
16
  import { failure, refusal } from '../stack/errors.mjs';
17
17
  import { firstLine, tool } from '../stack/context.mjs';
18
18
  import { ENTITY_LIST, hasEntityList } from './entity-list.mjs';
19
+ import { DB_DOCKERFILE, DB_IMAGE_CONTEXT } from './image.mjs';
20
+ import { BOOTSTRAP_FILE } from '../runtime/scripts/hfs/rules/database-constants.mjs';
21
+
22
+ export { BOOTSTRAP_FILE };
19
23
 
20
24
  export const DRIFT_CODE = 'BE_ENTITY_SCHEMA_DRIFT';
21
25
  export const MIGRATIONS_DIR = 'db/migrations';
22
- export const BOOTSTRAP_FILE = 'db/bootstrap.sql';
23
26
  const NEEDS_DATABASE = 'DB_DRIFT_NEEDS_DATABASE';
24
27
  const KEY = /^[A-Z][A-Z0-9_]*$/u;
25
28
  const MIGRATION = /^\d{14}_[a-z0-9][a-z0-9_-]*\.sql$/u;
@@ -141,27 +144,27 @@ export function migrationFiles(root) {
141
144
  return [...(fs.existsSync(path.join(root, BOOTSTRAP_FILE)) ? [BOOTSTRAP_FILE] : []), ...migrations];
142
145
  }
143
146
 
144
- /** The image of the app's db component (.starcistacks/application-stacks.yaml components.db.image), the one the scratch container runs. */
145
- export function scratchImage(root) {
146
- const image = readDeclaration(root)?.components?.db?.image;
147
- if (typeof image !== 'string' || image.trim() === '') throw refusal('DB_DRIFT_NO_IMAGE', 'declare components.db.image in .starcistacks/application-stacks.yaml (the scratch database runs the app\'s own db image) or pass --url-env');
148
- return image.trim();
149
- }
150
-
151
- /** Starts a throwaway container of `image` on a loopback port; returns { connection, stop }. Docker missing or failing is no verdict. */
152
- function startScratch(ctx, image) {
147
+ /**
148
+ * Builds the app's db image from its managed Dockerfile (DB_DOCKERFILE) and starts a throwaway container of it on a loopback port;
149
+ * returns { connection, stop }. Docker missing or failing is no verdict; an app without that Dockerfile is refused (sync writes it).
150
+ */
151
+ function startScratch(ctx, root) {
152
+ if (!fs.existsSync(path.join(root, DB_DOCKERFILE))) throw refusal('DB_DRIFT_NO_IMAGE', `${DB_DOCKERFILE} is missing: run "npx starci app sync --write" (the scratch database runs the app's own db image) or pass --url-env`);
153
153
  const docker = (args, env) => tool(ctx, 'docker', args, { env: { ...ctx.env, ...env } });
154
154
  const probe = docker(['version', '--format', '{{.Server.Version}}']);
155
155
  if (probe.error || probe.status !== 0) throw needsDatabase('Docker is not available on this host; start it, or pass --url-env naming an empty database');
156
+ const build = docker(['build', '--quiet', '--file', path.join(root, DB_DOCKERFILE), path.join(root, DB_IMAGE_CONTEXT)]);
157
+ const image = /sha256:[a-f0-9]{64}/u.exec(String(build.stdout ?? ''))?.[0];
158
+ if (build.status !== 0 || !image) throw needsDatabase(`docker could not build ${DB_DOCKERFILE}: ${firstLine(build.stderr)}`);
156
159
  const password = randomBytes(18).toString('hex');
157
160
  const name = `starci-db-drift-${randomBytes(6).toString('hex')}`;
158
161
  const run = docker(['run', '-d', '--rm', '--name', name, '--label', 'starci.db-drift=1', '-e', 'POSTGRES_PASSWORD', '-p', '127.0.0.1::5432', image], { POSTGRES_PASSWORD: password });
159
162
  const stop = () => { docker(['rm', '-f', name]); };
160
- if (run.status !== 0) { stop(); throw needsDatabase(`docker could not start ${image}: ${firstLine(run.stderr)}`); }
163
+ if (run.status !== 0) { stop(); throw needsDatabase(`docker could not start the image of ${DB_DOCKERFILE}: ${firstLine(run.stderr)}`); }
161
164
  const port = docker(['port', name, '5432/tcp']);
162
165
  const match = /:(\d+)\s*$/mu.exec(String(port.stdout ?? '').split('\n')[0]);
163
166
  if (port.status !== 0 || !match) { stop(); throw needsDatabase('docker published no port for the scratch database'); }
164
- return { connection: { host: '127.0.0.1', port: Number(match[1]), user: 'postgres', password, database: 'postgres' }, stop, how: `scratch container of ${image}` };
167
+ return { connection: { host: '127.0.0.1', port: Number(match[1]), user: 'postgres', password, database: 'postgres' }, stop, how: `scratch container of the image ${DB_DOCKERFILE} builds` };
165
168
  }
166
169
 
167
170
  /** Connects with the app's pg, retrying while a fresh container is still initialising; gives up after READY_TIMEOUT_MS. */
@@ -200,7 +203,7 @@ export async function migratedSchema(ctx, appRoot, { urlEnv } = {}) {
200
203
  if (typeof value !== 'string' || !/^postgres(?:ql)?:\/\//u.test(value)) throw needsDatabase(`${urlEnv} holds no postgres connection URL`);
201
204
  secrets = [value];
202
205
  scratch = { connection: { connectionString: value }, stop: () => {}, how: `the database ${urlEnv} names` };
203
- } else scratch = startScratch(ctx, scratchImage(root));
206
+ } else scratch = startScratch(ctx, root);
204
207
  const scrub = (text) => secrets.reduce((out, secret) => out.replaceAll(secret, '[value]'), String(text));
205
208
  let client;
206
209
  try {
package/db/image.mjs ADDED
@@ -0,0 +1,13 @@
1
+ // image.mjs - the one name of a lite app's db image (slot app.db.image, rulings lite-official-postgres-image and lite-db-image-in-stacks):
2
+ // the managed .starcistacks/images/db/Dockerfile builds it, `deploy.images.db` of .starcistacks/application-stacks.yaml names it as app
3
+ // `db`, and the stack build, the images and stack-deploy workflows, the local db container, the drift scratch database and the squash
4
+ // replay all build that one file. db/ keeps only schema and data (migrations, bootstrap, seed).
5
+
6
+ /** The image app name a deploy block gives the db service. */
7
+ export const DB_IMAGE_APP = 'db';
8
+ /** The build context of the db image, app-relative: the folder of its Dockerfile, which copies nothing from it. */
9
+ export const DB_IMAGE_CONTEXT = '.starcistacks/images/db';
10
+ /** The managed Dockerfile of the db image, app-relative. */
11
+ export const DB_DOCKERFILE = `${DB_IMAGE_CONTEXT}/Dockerfile`;
12
+ /** Where the db image's Dockerfile lived before it moved into the stacks; `starci app sync --write` removes it. */
13
+ export const RETIRED_DB_DOCKERFILE = 'db/Dockerfile';
package/db/migrate.mjs CHANGED
@@ -12,14 +12,14 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
14
14
  import { byCodeUnit } from '../runtime/engine/by-code-unit.mjs';
15
- import { MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
15
+ import { BOOTSTRAP_FILE, MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
16
+
17
+ export { BOOTSTRAP_FILE };
16
18
  import { approvalMissing, failure, refusal } from '../stack/errors.mjs';
17
19
  import { readDeclaration, runVerb } from '../release/index.mjs';
18
20
  import { DEFAULT_DB_URL_ENV, HISTORY_DDL, dbTarget, historyVersions, markApplied, markReverted, psqlTransaction } from './postgres.mjs';
19
21
 
20
22
  const DATABASE_CODE = 'DB_CONFIG_INVALID';
21
- /** The managed pre-migration bootstrap (slot app.db.bootstrap), applied before the first migration of an empty database. */
22
- export const BOOTSTRAP_FILE = 'db/bootstrap.sql';
23
23
 
24
24
  const SEGMENT = /^[A-Za-z0-9._-]+$/u;
25
25
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
package/db/postgres.mjs CHANGED
@@ -6,7 +6,7 @@ import { firstLine, tool } from '../stack/context.mjs';
6
6
 
7
7
  /** The default environment variable that names the app's database. */
8
8
  export const DEFAULT_DB_URL_ENV = 'DATABASE_URL';
9
- /** The history table `starci app db migrate` records applied migrations in (the layout the supabase/postgres image already ships). */
9
+ /** The history table `starci app db migrate` records applied migrations in (Supabase's layout, so a history restored from a hosted Supabase carries over; migrate creates it on an empty database). */
10
10
  export const HISTORY_TABLE = 'supabase_migrations.schema_migrations';
11
11
 
12
12
  const KEY = /^[A-Z][A-Z0-9_]*$/u;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.10.1",
3
+ "version": "4.11.8",
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",
@@ -43,6 +43,7 @@
43
43
  "rxjs": "7.8.2",
44
44
  "typeorm": "0.3.31",
45
45
  "typescript": "5.9.3",
46
+ "undici": "6.29.0",
46
47
  "zod": "4.6.7"
47
48
  },
48
49
  "files": [
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  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.'
33
33
  '@starci/grammar':
34
- version: 0.15.12
34
+ version: 0.15.20
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  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.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.7.1
41
+ version: 3.8.7
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  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.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.5.1
48
+ version: 9.5.9
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  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.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.15.12
55
+ version: 0.15.20
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.15.12
62
+ version: 0.15.20
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -92,12 +92,12 @@ pins:
92
92
  source: packages/jest-preset/package.json
93
93
  why: 'the Jest preset of a back end: the world runner runs up to min(--maxWorkers, slots) world files at once, each in its own process bound to one data slot of the test world (STARCI_TEST_WORLD_SLOT), and reads test-world state protocol 2 only: it pairs exactly with @starci/test-world, and any other state file is JEST_PRESET_WORLD_PAIR_MISMATCH. The unit run writes the lcov Sonar and Codecov import (services only); the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; it ships the unit kit (mockEntityManager, fakeTransaction, fakeQueryBuilder, queueQueryBuilders, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
94
94
  '@starci/test-world':
95
- version: 1.2.3
95
+ version: 1.3.1
96
96
  group: starci
97
97
  install: registry
98
98
  side: be
99
99
  source: packages/test-world/package.json
100
- 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.'
100
+ 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. A fake declared https (defineHttpFake) serves TLS with the run''s ephemeral in-memory fake CA and publishes its PEM as w.fake.<name>.ca for the app''s own client to trust; verification is never turned off. 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.'
101
101
  '@starci/ai-router':
102
102
  version: 0.3.0
103
103
  group: starci
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.10.1
109
+ version: 4.11.8
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -594,12 +594,27 @@ pins:
594
594
  group: framework
595
595
  side: be
596
596
  why: 'the Express 5 integration @nestjs/apollo loads at boot (R111 nestjs-apollo-express5, knowledge/hfs/peer-integrations.yaml); every api app that serves GraphQL installs it; the latest registry version on 2026-10-13.'
597
+ undici:
598
+ version: 6.29.0
599
+ group: framework
600
+ side: be
601
+ why: 'the Agent of platform/http (its one owner, R90): FetchHttpClient hands fetch a pool that trusts HttpOptions.trustedCa beside the bundled roots (a test world''s per-run fake CA); the 6 line is the undici the Node 22 runtime of the app images bundles, and its Agent also serves the fetch of Node 24; 6.29.0 is the release of 2026-09-25, chosen over newer ones that had not aged two weeks.'
597
602
  # --- lite identity (integrations/passport, ruling lite-self-hosted-postgres-passport-minio)
598
603
  '@nestjs/passport':
599
604
  version: 11.0.5
600
605
  group: framework
601
606
  side: be
602
607
  why: 'PassportStrategy of integrations/passport; 11.0.5 is CommonJS on the Nest 11 line (12.0.0 is ESM-only and breaks Nest 11 jest under the CommonJS transform of @starci/jest-preset), as nivo-lite runs it.'
608
+ '@nestjs/schedule':
609
+ version: 6.1.3
610
+ group: framework
611
+ side: be
612
+ why: 'the tick scheduler of platform/jobs, the one owner that imports it (R335, ruling lite-official-postgres-image); 6.1.3 is CommonJS on the Nest 11 line (12.x is ESM-only and breaks Nest 11 jest under the CommonJS transform of @starci/jest-preset).'
613
+ cron:
614
+ version: 4.4.0
615
+ group: framework
616
+ side: be
617
+ why: 'CronJob of the platform/jobs tick scheduler; the exact version @nestjs/schedule 6.1.3 depends on, so one cron is installed.'
603
618
  passport:
604
619
  version: 0.7.0
605
620
  group: framework
@@ -1407,7 +1407,7 @@ rules:
1407
1407
  witness:
1408
1408
  - {"path":"packages/eslint/be/legacy-sql-http-log-proof.spec.mjs","edition":"lite","codes":["BE_SQL_INTERPOLATED"]}
1409
1409
  - {"path":"packages/eslint/be/legacy-sql-http-log-proof.spec.mjs","edition":"full","codes":["BE_SQL_INTERPOLATED"]}
1410
- law: "SQL text carries no runtime substitution; values are numbered parameters."
1410
+ law: "SQL text carries no runtime substitution; values are numbered parameters. The one exception is R334's lite call statement in platform/database/database.client.ts, which splices only the name parts and parameter list its own validator checked."
1411
1411
  kinds: [lint, codemod]
1412
1412
  gates: [pre-commit, settle, land, ci]
1413
1413
  failureCodes: ["BE_SQL_INTERPOLATED"]
@@ -3695,7 +3695,7 @@ rules:
3695
3695
  editions: [lite, full]
3696
3696
  docs: [docs/architecture.md#r213]
3697
3697
  templateNa: "Full apps keep TypeORM migrations under be/; the managed db/migrations scaffold is lite's."
3698
- law: "A migration is `db/migrations/<ts14>_<kebab>.sql`; the stamp is a valid UTC instant, active stamps are unique, and a new migration sorts strictly after the committed active migrations in HEAD. Repository history includes the archive: moving an active migration to the archive retires it from ordering, so a replacement baseline may predate archived stamps and later migrations append after the baseline. Wall-clock time, commit time and remote-branch stamps do not set the ordering boundary. A migration already on the base is immutable (a diff or an unarchived removal is a finding); a file that does not parse as PostgreSQL is a shape failure."
3698
+ law: "A migration is `db/migrations/<ts14>_<kebab>.sql`; the stamp is a valid UTC instant, active stamps are unique, and a new migration sorts strictly after the committed active migrations in HEAD. Repository history includes the archive: moving an active migration to the archive retires it from ordering, so a replacement baseline may predate archived stamps and later migrations append after the baseline. Wall-clock time, commit time and remote-branch stamps do not set the ordering boundary. A migration already on the base is immutable (a diff or an unarchived removal is a finding); a file that does not parse as PostgreSQL is a shape failure. `starci app db migrate` applies each migration in one transaction with its history row, so a top-level BEGIN, START TRANSACTION, COMMIT, END, ROLLBACK, ABORT, SAVEPOINT, RELEASE or PREPARE TRANSACTION, or a COMMIT or ROLLBACK inside a DO block, is a shape failure named by file:line; a function or procedure body is never judged."
3699
3699
  kinds: [check]
3700
3700
  gates: [settle, land, ci]
3701
3701
  failureCodes: ["DB_MIGRATION_SHAPE"]
@@ -4176,7 +4176,7 @@ rules:
4176
4176
  editions: [lite, full]
4177
4177
  docs:
4178
4178
  - {path: docs/application-stacks.md#r250-deploy-scan, editions: [lite, full]}
4179
- law: "In lite and full apps, scheduled work belongs to the back-end queue scheduler, a pg_cron tick that enqueues through the queue outbox or a declared replicated-job. Deployment files, scripts, workflows, compose files and Dockerfiles install no host crontab, cron.d entry, systemd timer, @reboot job or at job; a workflow does not schedule app work. Docker compose up, down and pull run only against an explicitly dev environment. Deploy shell scripts delegate to docker, starci or the app CLI, and contain no node -e, psql, inline SQL or downloaded shell logic."
4179
+ law: "In lite and full apps, scheduled work belongs to the back-end queue scheduler, a platform/jobs tick (R335) that enqueues through the queue outbox or a declared replicated-job. Deployment files, scripts, workflows, compose files and Dockerfiles install no host crontab, cron.d entry, systemd timer, @reboot job or at job; a workflow does not schedule app work. Docker compose up, down and pull run only against an explicitly dev environment. Deploy shell scripts delegate to docker, starci or the app CLI, and contain no node -e, psql, inline SQL or downloaded shell logic."
4180
4180
  kinds: [check]
4181
4181
  gates: [settle, land, ci]
4182
4182
  failureCodes: ["HFS_HOST_CRON", "HFS_DEPLOY_NO_LOGIC", "HFS_DEPLOY_COMPOSE_ON_VPS", "HFS_DEPLOY_SCHEDULER"]
@@ -4310,22 +4310,21 @@ rules:
4310
4310
  - id: R258
4311
4311
  code: "DB_PGMQ_DECLARED"
4312
4312
  editions: [lite]
4313
- editionWhy: "owner:lite-backing-services makes the db service lite's database and ruling:R212/case-10 moves the lite queue to Redis; the pgmq retirement and pg_cron ownership requirements apply to that backing."
4313
+ editionWhy: "ruling:lite-pgmq-bus-bullmq-jobs makes pgmq in the db service the lite event bus while jobs run on BullMQ over the declared Redis; full's bus is Kafka."
4314
4314
  docs: [docs/microservices.md#r258]
4315
- templateNa: "Queue declarations and enqueue-only cron commands depend on product-owned work and are authored in its db migrations."
4316
- law: "DB_QUEUE_CRON: the queue is BullMQ on Redis (ruling:R212/case-10), so the active migrations leave no pgmq queue named by a queue constant under modules/queues; a pgmq queue a migration created for one is dropped by a later migration. Every pgmq queue the active migrations leave is an event-bus topic queue named by a literal *_TOPIC_QUEUE constant under platform/event-bus, every such constant has its pgmq queue, and pgmq objects are granted only to the application role while they exist. A pg_cron job belongs to a migration and only enqueues; its name is scheduled once while it is active, so a later migration schedules it again only after its own cron.unschedule of that name, and one tick function has one live job at a time. Its command, directly or through one enqueue-only *_tick function, inserts one row of a literal queue constant into the queue outbox table of ruleParams.db.queueOutbox, writing only its declared columns (the relay hands it to BullMQ). An app whose hfs.json sides.be.backing declares redis may declare its own outbox in sides.be.queueOutbox ({table, enqueue, queueParameter}); the command, or the tick, then calls the declared enqueue function with a literal queue constant for the declared parameter and pure other arguments, while the active definition of that function only inserts one row into the declared table with its queue column bound to that parameter (an ON CONFLICT DO NOTHING, a read of the declared table and pure expressions are the only other work). A direct insert into the app's outbox table, or any other function, stays refused. The job executes no business DML or HTTP request and shares no tick with an app scheduler. A pgmq.send validates only in the original body of a job an older migration scheduled while that queue existed; the live job and its current tick never enqueue that way. Removing a tick unschedules its job."
4315
+ templateNa: "Topic queues and their bindings depend on product-owned consumer groups and are authored in its db migrations."
4316
+ law: "DB_PGMQ_DECLARED: pgmq is the event bus and jobs run on BullMQ (ruling:lite-pgmq-bus-bullmq-jobs), so the active migrations leave no pgmq queue named by a queue constant under modules/queues; a pgmq queue a migration created for one is dropped by a later migration. Every pgmq queue the active migrations leave is an event-bus topic queue named by a literal *_TOPIC_QUEUE constant under platform/event-bus, every such constant has its pgmq queue, a topic binding (pgmq.bind_topic) names a literal pattern and a queue an earlier pgmq.create declared, no queue or binding is created by dynamic SQL or inside a function, and pgmq objects are granted only to the application role while they exist."
4317
4317
  kinds: [check]
4318
4318
  gates: [settle, land, ci]
4319
- failureCodes: ["DB_PGMQ_DECLARED", "DB_CRON_OWNED"]
4319
+ failureCodes: ["DB_PGMQ_DECLARED"]
4320
4320
  enforcers:
4321
4321
  - {kind: hfs, id: db-queue-cron, at: scripts/hfs/rules/database-queues.mjs}
4322
4322
  proves:
4323
- - {path: packages/hfs/src/hfs-database-queues.spec.mjs, edition: lite, codes: [DB_PGMQ_DECLARED, DB_CRON_OWNED]}
4324
- - {path: packages/hfs/src/hfs-queue-outbox-declaration.spec.mjs, edition: lite, codes: [DB_CRON_OWNED]}
4323
+ - {path: packages/hfs/src/hfs-database-queues.spec.mjs, edition: lite, codes: [DB_PGMQ_DECLARED]}
4325
4324
  exemplifiedBy:
4326
- - {path: packages/hfs/src/hfs-database-queues.spec.mjs, edition: lite, codes: [DB_PGMQ_DECLARED, DB_CRON_OWNED]}
4325
+ - {path: packages/hfs/src/hfs-database-queues.spec.mjs, edition: lite, codes: [DB_PGMQ_DECLARED]}
4327
4326
  witness:
4328
- - {path: packages/hfs/src/hfs-database-queues.spec.mjs, edition: lite, codes: [DB_PGMQ_DECLARED, DB_CRON_OWNED]}
4327
+ - {path: packages/hfs/src/hfs-database-queues.spec.mjs, edition: lite, codes: [DB_PGMQ_DECLARED]}
4329
4328
  - id: R259
4330
4329
  code: "DB_EXTENSION_ALLOWED"
4331
4330
  editions: [lite]
@@ -4701,7 +4700,7 @@ rules:
4701
4700
  - {path: packages/hfs/src/hfs-stacks-full-fixtures.spec.mjs, edition: full}
4702
4701
  - id: R276
4703
4702
  code: "STACKS_LITE_SELFHOSTED_DB"
4704
- law: "Lite runs exactly one database, its db component of image supabase/postgres (pinned by digest) with pgvector for vector search, and no other PostgreSQL, MongoDB or Qdrant backing image, even when declared (owner:lite-vector-is-pgvector, ruling:lite-self-hosted-postgres-passport-minio). A Redis image runs only when hfs.json sides.be.backing declares redis (ruling:R212/case-10), as one minimal instance; a declared Redis runs as exactly one stack service, with AOF persistence (`appendonly yes`, `appendfsync everysec` or `always`, never `no`) on a writable named volume so queue jobs survive a restart and with no maxmemory-policy but noeviction so none is evicted. A queue job Redis loses inside the at most one second `everysec` leaves unwritten is not lost work: every job is born from a Postgres outbox row, and the relay marks that row sent only after Redis confirms the job is in its append-only file (WAITAOF) and still holds it, so a job Redis did not persist leaves its row waiting and the relay adds it again (examples/lite-app queue-relay.service.spec.ts and bullmq-queue-transport.client.spec.ts). A sidecar keeps its own SQLite on a named persistent volume declared by its component and mounted in the rendered model, and never uses the app's declared Redis, which serves the back-end app services only; a sidecar requiring another database, that Redis included, is refused. Full retains its declared backing topology."
4703
+ law: "Lite runs exactly one database, its db component of the image its managed .starcistacks/images/db/Dockerfile builds (the digest-pinned official postgres image plus pgvector, ruling:lite-official-postgres-image) for vector search too, and no other PostgreSQL, MongoDB or Qdrant backing image, even when declared (owner:lite-vector-is-pgvector, ruling:lite-self-hosted-postgres-passport-minio). A Redis image runs only when hfs.json sides.be.backing declares redis (ruling:R212/case-10), as one minimal instance; a declared Redis runs as exactly one stack service, with AOF persistence (`appendonly yes`, `appendfsync everysec` or `always`, never `no`) on a writable named volume so queue jobs survive a restart and with no maxmemory-policy but noeviction so none is evicted. A queue job Redis loses inside the at most one second `everysec` leaves unwritten is not lost work: every job is born from a Postgres outbox row, and the relay marks that row sent only after Redis confirms the job is in its append-only file (WAITAOF) and still holds it, so a job Redis did not persist leaves its row waiting and the relay adds it again (examples/lite-app queue-relay.service.spec.ts and bullmq-queue-transport.client.spec.ts). A sidecar keeps its own SQLite on a named persistent volume declared by its component and mounted in the rendered model, and never uses the app's declared Redis, which serves the back-end app services only; a sidecar requiring another database, that Redis included, is refused. Full retains its declared backing topology."
4705
4704
  kinds: [check]
4706
4705
  gates: ["settle","land","ci"]
4707
4706
  failureCodes: ["STACKS_LITE_SELFHOSTED_DB", "STACKS_SIDECAR_VOLUME_UNDECLARED", "STACKS_SIDECAR_DB_UNSUPPORTED", "STACKS_LITE_REDIS_PERSISTENCE"]
@@ -5829,7 +5828,7 @@ rules:
5829
5828
  ruling: "Lite entities only map tables the migrations create, so each entity column is checked against the migrated schema by starci app db drift, which CI runs in the build job and the release gate runs as its db-drift row."
5830
5829
  - id: R334
5831
5830
  code: "BE_PERSISTENCE_NO_RAW_SQL"
5832
- law: "Lite domain and feature code reads and writes through TypeORM entities on the injected EntityManager (find, findOne, findBy, count, exists, insert, save, update, delete, upsert, increment, and createQueryBuilder(Entity, alias) with named parameters for joins, aggregates, FOR UPDATE / SKIP LOCKED and jsonb or vector operators; never a table-name builder, a builder with no entity or a Repository); no .query( call and no sql template outside platform/database; a database function TypeORM cannot express is called through the callDbFunction, advisoryXactLock and notify helpers of platform/database, and callDbFunction names only a function ruleParams.be.dbFunctions or hfs.json sides.be.dbFunctions lists with a reason. Entities only map tables db/migrations created (synchronize stays false); entity-versus-schema drift is BE_ENTITY_SCHEMA_DRIFT of starci app db drift."
5831
+ law: "Lite domain and feature code reads and writes through TypeORM entities on the injected EntityManager (find, findOne, findBy, count, exists, insert, save, update, delete, upsert, increment, and createQueryBuilder(Entity, alias) with named parameters for joins, aggregates, FOR UPDATE / SKIP LOCKED and jsonb or vector operators; never a table-name builder, a builder with no entity or a Repository); no .query( call and no sql template outside platform/database; a database function TypeORM cannot express is called through the callDbFunction, advisoryXactLock and notify helpers of platform/database/database.client.ts, and callDbFunction names only a function ruleParams.be.dbFunctions or hfs.json sides.be.dbFunctions lists with a reason: a function of a migration schema-qualified (public.job_claim), a pg built-in bare (pg_notify, called in pg_catalog). That file alone composes the call statement, from the name parts its validator checked and double-quotes and a numbered parameter list. Entities only map tables db/migrations created (synchronize stays false); entity-versus-schema drift is BE_ENTITY_SCHEMA_DRIFT of starci app db drift."
5833
5832
  kinds: [lint]
5834
5833
  gates: [pre-commit, settle, land, ci]
5835
5834
  failureCodes: ["BE_PERSISTENCE_NO_RAW_SQL"]
@@ -5849,3 +5848,45 @@ rules:
5849
5848
  source: owner
5850
5849
  quote: "big refactor đi … không viết raw sql như thế theo starci-academy-be"
5851
5850
  ruling: "Lite persistence moves to TypeORM entities on the EntityManager, the shape of full and starci-academy-be (ruling lite-persistence-typeorm-entities)."
5851
+ - id: R335
5852
+ code: "BE_JOBS_SCHEDULER_OWNER"
5853
+ law: "Recurring work is a tick of platform/jobs (ruling lite-official-postgres-image): JOB_TICKS of job-ticks.contracts.ts lists the ticks as data, and only job-tick.service.ts (JobTickService, which registers one CronJob per tick in SchedulerRegistry) and jobs.module.ts (which composes ScheduleModule.forRoot()) import @nestjs/schedule or cron; no file uses the Cron, Interval or Timeout decorator. JobTickService takes advisoryXactLock(tx, \"tick:<name>\") and claims the tick's UTC minute through callDbFunction(tx, \"job_tick_claim\", ...) in the transaction that writes the queue outbox row, so of the replicas that fire a tick exactly one enqueues it once per minute."
5854
+ kinds: [lint]
5855
+ gates: [pre-commit, settle, land, ci]
5856
+ failureCodes: ["BE_JOBS_SCHEDULER_OWNER"]
5857
+ enforcers:
5858
+ - {kind: eslint-be, id: jobs-scheduler-owner}
5859
+ editions: [lite, full]
5860
+ docs: [contexts/code/be/jobs.md]
5861
+ proves:
5862
+ - packages/eslint-canon-be/src/jobs-scheduler.spec.ts
5863
+ exemplifiedBy:
5864
+ - {path: packages/hfs/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl, edition: lite}
5865
+ - {path: packages/eslint-canon-be/src/jobs-scheduler.spec.ts, editions: [lite, full], codes: [BE_JOBS_SCHEDULER_OWNER]}
5866
+ witness:
5867
+ - {path: packages/eslint-canon-be/src/jobs-scheduler.spec.ts, edition: lite, codes: [BE_JOBS_SCHEDULER_OWNER]}
5868
+ - {path: packages/eslint-canon-be/src/jobs-scheduler.spec.ts, edition: full, codes: [BE_JOBS_SCHEDULER_OWNER]}
5869
+ cases:
5870
+ - id: case-1
5871
+ date: "2026-10-29"
5872
+ source: owner
5873
+ quote: "chạy postgresql thuần image chính chủ nhé ko phải supebase"
5874
+ ruling: "The lite db image carries no pg_cron, so recurring work moves to a Nest schedule tick of platform/jobs under an advisory lock and a minute claim (ruling lite-official-postgres-image)."
5875
+ - id: R336
5876
+ code: "BE_EVENT_BUS_LITE_SHAPE"
5877
+ law: "On the lite event bus (pgmq, delivery at least once and unordered), every event payload type declares dedupeKey, the business key a consumer inbox claims as (consumer, dedupeKey), and a reactor subscribes with a *_TOPIC_QUEUE constant imported from platform/event-bus and literal options, so every subscription is static data. The payload type is a literal, EventPayload, or an interface or type alias of the event file that declares dedupeKey or extends EventPayload (ruling lite-pgmq-bus-bullmq-jobs)."
5878
+ kinds: [lint]
5879
+ gates: [pre-commit, settle, land, ci]
5880
+ failureCodes: ["BE_EVENT_BUS_LITE_SHAPE"]
5881
+ enforcers:
5882
+ - {kind: eslint-be, id: event-dedupe-key}
5883
+ - {kind: eslint-be, id: event-subscription-static}
5884
+ editions: [lite]
5885
+ docs: [contexts/code/be/event-bus.md]
5886
+ proves:
5887
+ - packages/eslint-canon-be/src/event-bus-lite.spec.ts
5888
+ exemplifiedBy:
5889
+ - {path: packages/hfs/templates/be/inbox/delivery-recorded.event.ts.tpl, edition: lite}
5890
+ - {path: packages/eslint-canon-be/src/event-bus-lite.spec.ts, edition: lite, codes: [BE_EVENT_BUS_LITE_SHAPE]}
5891
+ witness:
5892
+ - {path: packages/eslint-canon-be/src/event-bus-lite.spec.ts, edition: lite, codes: [BE_EVENT_BUS_LITE_SHAPE]}