@starci/hfs 4.11.0 → 4.11.11

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 (140) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +43 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +30 -13
  9. package/runtime/knowledge/hfs/slots.yaml +63 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/api/sops/lib.mjs +30 -0
  16. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  17. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  18. package/runtime/scripts/guards/push-scan.mjs +1 -1
  19. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  20. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  21. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  22. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  23. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  25. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  26. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  28. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  29. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  30. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  31. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  32. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  33. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  34. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  36. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  39. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  41. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  44. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  46. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  47. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  48. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  49. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  50. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  51. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  52. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +108 -9
  54. package/runtime/scripts/hfs/secret-standard.mjs +369 -0
  55. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  56. package/runtime/scripts/hfs/secret.mjs +140 -46
  57. package/runtime/scripts/hfs/slots.mjs +8 -1
  58. package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
  59. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  60. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  61. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  62. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  63. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  64. package/scaffold/add-inbox-lite.mjs +2 -2
  65. package/scaffold/app.mjs +2 -2
  66. package/src/main.mjs +6 -4
  67. package/stack/age-install.mjs +4 -3
  68. package/stack/build.mjs +1 -1
  69. package/stack/config.mjs +2 -1
  70. package/stack/context.mjs +32 -2
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +8 -5
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/server-key.mjs +296 -0
  75. package/stack/ssh-session.mjs +7 -1
  76. package/stack/target-binding.mjs +2 -1
  77. package/sync/hygiene.mjs +1 -1
  78. package/sync/index.mjs +36 -10
  79. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  80. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  81. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  82. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  83. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  84. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  85. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  86. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  87. package/templates/app/gitignore +2 -1
  88. package/templates/app/gitignore-lite +3 -1
  89. package/templates/app/package-scripts/db.json +1 -1
  90. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  91. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  92. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  93. package/templates/app/skeleton-lite/README.md +2 -2
  94. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  95. package/templates/be/inbox/events.json.tpl +1 -1
  96. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  97. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  98. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  99. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  100. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  101. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  102. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  103. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  104. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  105. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  106. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  107. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  108. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  109. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  110. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  111. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  112. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  113. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  114. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  115. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  118. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  119. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  120. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  121. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  122. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  123. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  124. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
  125. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
  126. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  127. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  128. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  129. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  130. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  131. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  132. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  133. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  134. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  135. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  136. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  137. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
  138. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  139. package/templates/app/db-image/db/Dockerfile +0 -8
  140. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
package/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.11.11 - 2026-10-29
4
+
5
+ - Changed: `server-key <env> --backup github` stores the server identity as the secret `SOPS_AGE_KEY` of the GitHub Environment `<env>` (`BACKUP_SECRET`, `gh secret set SOPS_AGE_KEY --env <env>`), the name the deploy workflows read: an Environment secret overrides a repository secret of the same name for every job bound to that Environment, so the deploy of `<env>` decrypts with that environment's own server key. `backupSecretName` and the `SOPS_AGE_KEY_<ENV>` name are gone.
6
+ - Changed: `server-key <env> --restore github` dispatches `stack-key-restore.yml` with `-f recipient=<deploy.remote.serverRecipient>` instead of a secret name, and is refused `STACK_SERVER_KEY_NO_RECIPIENT` when none is recorded. The managed workflow (both editions) reads `secrets.SOPS_AGE_KEY` of the bound environment, takes the `recipient` input in place of `secret`, and on the server writes the identity beside its target, keeping it only when `age-keygen -y` of it is that recipient (exit 6 otherwise, nothing written), so neither the owner key nor a stale identity can land there.
7
+ - Added: `--backup github` and `--restore github`, dry run or not, read the repository's Actions secret names (`gh api repos/<o>/<r>/actions/secrets`, names only, `repositoryKeyWarnings`) and warn while a repository-level `SOPS_AGE_KEY` exists, naming `gh secret delete SOPS_AGE_KEY --repo <o>/<r>`; the owner key never belongs in GitHub. An unreadable list is a warning too.
8
+ - Changed: R251 (`HFS_DEPLOY_WORKFLOW`, `rules/deploy-workflow.mjs`) refuses any app workflow job, deployment job or not, that reads `secrets.SOPS_AGE_KEY` (job or workflow `env`, an active step's `env`, `with` or `run`, a reusable workflow's `secrets`) without an `environment`; a disabled step does not count. The managed `stack-deploy.yml` (both jobs) and `images.yml` (when an environment declares images) already bind theirs; their headers now say the key they read is that environment's server identity.
9
+ - Changed: the bundled canon pins name hfs 4.11.11, eslint-canon-be 3.8.10, eslint-canon-fe 9.5.12 and the Grammar family 0.15.23.
10
+
11
+ ## 4.11.10 - 2026-10-29
12
+
13
+ - Fixed: the skeleton's `platform/http` passes the canon it ships with, so an app that copies it byte-identical lints clean. `FetchHttpClient.send` hands `fetch` its deadline at the call (`fetch(request.url, { ...this.routed(init), signal })`): the signal was set inside `init` and handed through `routed`, whose declared `RequestInit` leaves `signal` optional, so `starci-be/http-needs-timeout` (R70) could not prove it. The client spec provides `HTTP_OPTIONS` with the kit's `builder<HttpOptions>({})(overrides)`, the double `starci-be/spec-infra-double-from-kit` (R48) asks for an `*_OPTIONS` token, instead of a parameter it cannot trace.
14
+ - Fixed: the lite skeleton's api app reads `http: parseHttpConfig(env)` into its options (`api.env.ts`, `api.options.ts`, the wiring proof's `http: {}`) and registers `HttpModule.register({ isGlobal: true, ...options.http })`, as the full app does, so a lite test world passes its fake CA as `HTTP_TRUSTED_CA` too and `parseHttpConfig` and `HttpOptions` are no longer exports nothing imports (`starci-be/dead-exports`).
15
+ - Changed: the bundled canon pins name hfs 4.11.10, eslint-canon-be 3.8.9, eslint-canon-fe 9.5.11 and the Grammar family 0.15.22.
16
+ - Spec: `src/scaffold-template-canon-be.spec.mjs` scaffolds each edition and lints its `be/src/modules/platform/http/**` (code and specs) with the starci-be rules of the sibling eslint-canon-be, typed against this package's installs and the built kit, and asserts no finding; on the skeleton before this fix it names exactly the R70 and R48 findings above.
17
+
18
+ ## 4.11.9 - 2026-10-29
19
+
20
+ - Added: `starci app secret server-key <env> [--execute]` makes the deploy server's own age identity ON the server, reached through the deploy's own transport (`deploy.remote`, `withRunner`: the same host, user and key source; an environment whose deploy declares no remote is refused `STACK_SERVER_KEY_NO_REMOTE` before any ssh). The server keeps `/root/.config/sops/age/keys.txt` (`SERVER_AGE_IDENTITY`, `stack/server-key.mjs`, sops' default key file of root) when it exists, else `age-keygen -o` makes it (umask 077, mode 600, owner root); only `age-keygen -y` comes back, and any other output is refused unseen. age-keygen missing is installed by stack prepare's installer (`installAge`) or refused naming the install commands. Locally the recipient joins the environment's rule beside the owner key (`STACK_SERVER_KEY_NO_OWNER` when the rule names none), is recorded as `deploy.remote.serverRecipient`, and every member is wrapped again with `sops updatekeys` (`updateKeys` of the sops seam, `api/sops/update-keys.mjs`, `runSelectedUpdateKeys` for an inline identity): the owner identity opens the data key only, no value is decrypted. A dry run until `--execute`; a second run keeps the identity and changes nothing.
21
+ - Added: `server-key <env> --rotate --execute` stages a new identity at `<identity>.next`, wraps the members to it, swaps it in keeping the old file on the server as `<identity>.retired-<UTC stamp>` until the owner deletes it, then removes the old recipient and wraps them again.
22
+ - Added: `deploy.remote.serverRecipient` (a public age recipient) in the deploy block; `audit` raises `RECIPIENT_DRIFT` for an environment whose rule does not name it beside another recipient (the owner key), and for a production swarm deploy with a remote that records none.
23
+ - Added: `server-key <env> --backup github [--execute]` streams the server identity from the server's ssh stdout straight into the stdin of `gh secret set SOPS_AGE_KEY_<ENV> --env <env> --repo <origin>` (the stack context's `pipe` seam and the ssh runner's `argv(command)`): never printed, held or written locally. It is refused unless that GitHub Environment exists with required reviewers or deployment branches limited to main (`STACK_SERVER_KEY_GITHUB_ENVIRONMENT`, `STACK_SERVER_KEY_UNPROTECTED`). `--restore github` dispatches the managed `.github/workflows/stack-key-restore.yml` (slot `app.ci-stack-key-restore`, slot manifest 3.14.0), which writes the secret to the server over the deploy's CI ssh key with umask 077 and mode 600, never echoing it and never over an existing identity.
24
+
25
+ ## 4.11.8 - 2026-10-29
26
+
27
+ - 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`).
28
+ - 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`).
29
+ - 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.
30
+ - 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`).
31
+ - 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.
32
+ - 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).
33
+
34
+ ## 4.11.7 - 2026-10-29
35
+
36
+ - 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`).
37
+ - 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".
38
+ - 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).
39
+ - 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.
40
+ - 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.
41
+ - 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.
42
+
43
+ ## 4.11.6 - 2026-10-29
44
+
45
+ - 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.
46
+ - 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).
47
+ - 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`).
48
+
49
+ ## 4.11.5 - 2026-10-29
50
+
51
+ - 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.
52
+ - 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.
53
+ - 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`.
54
+ - 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.
55
+ - 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.
56
+ - Changed: the managed `.gitignore` block re-includes `.starcistacks/**/secrets/*.enc.yaml` beside the legacy `*.enc`.
57
+ - Spec: `src/hfs-secret-standard.spec.mjs`.
58
+ - 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.
59
+ - Spec: `src/hfs-secret-set.spec.mjs` (fake values only; a child process with a forced-TTY stdin drives the real prompt).
60
+
61
+ ## 4.11.4 - 2026-10-29
62
+
63
+ - 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.
64
+
65
+ ## 4.11.3 - 2026-10-29
66
+
67
+ 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.
68
+
69
+ - 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.
70
+ - 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.
71
+ - 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.
72
+ - 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`.
73
+ - 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.
74
+
75
+ ## 4.11.2 - 2026-10-29
76
+
77
+ - 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`.
78
+ - 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`.
79
+ - 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.
80
+
81
+ ## 4.11.1 - 2026-10-29
82
+
83
+ - 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`.
84
+ - 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).
85
+ - 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`.
86
+ - Changed: the bundled canon pins name hfs 4.11.1.
87
+
3
88
  ## 4.11.0 - 2026-10-29
4
89
 
5
90
  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").
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|server-key ... [--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,29 @@ 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`). `server-key <env>` makes
238
+ the deploy server's own age identity on the server through the deploy's transport and seals the environment to it beside the
239
+ owner key, `--rotate` replaces it, `--backup github` stores it as the SOPS_AGE_KEY of the GitHub Environment `<env>` that
240
+ every CI job bound to `<env>` reads, and `--restore github` writes it back (`stack/server-key.mjs`). No output carries a value
241
+ except `get --reveal`.
242
+
201
243
  ## Sonar
202
244
 
203
245
  One mechanism for both sides: every finding of the canon is imported into Sonar, and the quality gate
package/db/drift.mjs CHANGED
@@ -3,7 +3,7 @@
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
5
  // DataSource that never connects, applies the migrations to a scratch database (a throwaway container of the image the app's managed
6
- // db/Dockerfile builds, or the empty database named by --url-env), reads that schema from
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
@@ -16,11 +16,13 @@ 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 } from './image.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 };
20
23
 
21
24
  export const DRIFT_CODE = 'BE_ENTITY_SCHEMA_DRIFT';
22
25
  export const MIGRATIONS_DIR = 'db/migrations';
23
- export const BOOTSTRAP_FILE = 'db/bootstrap.sql';
24
26
  const NEEDS_DATABASE = 'DB_DRIFT_NEEDS_DATABASE';
25
27
  const KEY = /^[A-Z][A-Z0-9_]*$/u;
26
28
  const MIGRATION = /^\d{14}_[a-z0-9][a-z0-9_-]*\.sql$/u;
@@ -143,15 +145,15 @@ export function migrationFiles(root) {
143
145
  }
144
146
 
145
147
  /**
146
- * Builds the app's db image from db/Dockerfile and starts a throwaway container of it on a loopback port; returns { connection, stop }.
147
- * Docker missing or failing is no verdict; an app without db/Dockerfile is refused (sync writes it).
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).
148
150
  */
149
151
  function startScratch(ctx, root) {
150
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`);
151
153
  const docker = (args, env) => tool(ctx, 'docker', args, { env: { ...ctx.env, ...env } });
152
154
  const probe = docker(['version', '--format', '{{.Server.Version}}']);
153
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');
154
- const build = docker(['build', '--quiet', '--file', path.join(root, DB_DOCKERFILE), path.join(root, 'db')]);
156
+ const build = docker(['build', '--quiet', '--file', path.join(root, DB_DOCKERFILE), path.join(root, DB_IMAGE_CONTEXT)]);
155
157
  const image = /sha256:[a-f0-9]{64}/u.exec(String(build.stdout ?? ''))?.[0];
156
158
  if (build.status !== 0 || !image) throw needsDatabase(`docker could not build ${DB_DOCKERFILE}: ${firstLine(build.stderr)}`);
157
159
  const password = randomBytes(18).toString('hex');
package/db/image.mjs CHANGED
@@ -1,8 +1,13 @@
1
- // image.mjs - the one name of a lite app's db image (slot app.db.image, ruling:lite-official-postgres-image): the managed db/Dockerfile
2
- // builds it, `deploy.images.db` of .starcistacks/application-stacks.yaml names it as app `db`, and the stack build, the images and
3
- // stack-deploy workflows, the local db container and the drift scratch database all build that one file.
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).
4
5
 
5
6
  /** The image app name a deploy block gives the db service. */
6
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';
7
10
  /** The managed Dockerfile of the db image, app-relative. */
8
- export const DB_DOCKERFILE = 'db/Dockerfile';
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.11.0",
3
+ "version": "4.11.11",
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.13
34
+ version: 0.15.23
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.8.0
41
+ version: 3.8.10
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.2
48
+ version: 9.5.12
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.13
55
+ version: 0.15.23
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.13
62
+ version: 0.15.23
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.11.0
109
+ version: 4.11.11
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -594,6 +594,11 @@ 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
@@ -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"]
@@ -4195,7 +4195,7 @@ rules:
4195
4195
  editions: [lite, full]
4196
4196
  docs:
4197
4197
  - {path: docs/application-stacks.md#r251-deploy-workflow, editions: [lite, full]}
4198
- law: "In lite and full apps, deployment jobs declare a protected environment, explicit permissions and concurrency, materialize sealed secrets through SOPS_AGE_KEY or starci app secret materialize, and reference only the age key and transport SSH key/known_hosts secrets. They never embed a dotenv blob or write a dotenv file. Every remote workflow action uses a 40-hex commit SHA (local actions are allowed and container actions require a full sha256 digest); supply-chain reports each unpinned uses reference at its source line, including disabled jobs and steps; installed tools are checksum or digest verified, or supplied by a pinned action. App workflows start only on release tags [v*], the release candidate branches release/candidate-* (required in ci.yml: starci app release push waits for that run, owner ruling candidate-ci 2026-10-18) or workflow_dispatch; ci.yml also runs on main, and other branch pushes are refused."
4198
+ law: "In lite and full apps, deployment jobs declare a protected environment, explicit permissions and concurrency, materialize sealed secrets through SOPS_AGE_KEY or starci app secret materialize, and reference only the age key and transport SSH key/known_hosts secrets; every job that reads secrets.SOPS_AGE_KEY, a deployment job or not, is bound to the GitHub Environment whose members it opens, so it reads that environment's server key and never a repository secret of that name. Deployment jobs never embed a dotenv blob or write a dotenv file. Every remote workflow action uses a 40-hex commit SHA (local actions are allowed and container actions require a full sha256 digest); supply-chain reports each unpinned uses reference at its source line, including disabled jobs and steps; installed tools are checksum or digest verified, or supplied by a pinned action. App workflows start only on release tags [v*], the release candidate branches release/candidate-* (required in ci.yml: starci app release push waits for that run, owner ruling candidate-ci 2026-10-18) or workflow_dispatch; ci.yml also runs on main, and other branch pushes are refused."
4199
4199
  kinds: [check]
4200
4200
  gates: [settle, land, ci]
4201
4201
  failureCodes: ["HFS_DEPLOY_WORKFLOW", "HFS_WORKFLOW_PIN", "HFS_CI_TOOL_INSTALL", "HFS_APP_WORKFLOW_TRIGGER"]
@@ -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 the image its managed 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."
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"]
@@ -5873,3 +5872,21 @@ rules:
5873
5872
  source: owner
5874
5873
  quote: "chạy postgresql thuần image chính chủ nhé ko phải supebase"
5875
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]}