@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
@@ -7,7 +7,7 @@
7
7
  # catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
8
8
  # Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
9
9
  schema: starci/hfs-slots@3
10
- version: 3.10.0
10
+ version: 3.13.0
11
11
 
12
12
  versioning:
13
13
  # MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
@@ -52,6 +52,12 @@ versioning:
52
52
  # slot field rewrittenBy, so the release gate's clean-tree check passes over an untracked file a declared dev server writes, and over nothing else.
53
53
  # 3.10.0 is a minor (2026-10-29): it ADDS the lite be.platform.database.entities slot, the entity registry (slot fields role and importedBy), so the
54
54
  # tier matrix and the public-entry rules admit exactly its <c>Entities imports and its deep importers (persistence.md BE-PERSISTENCE-3).
55
+ # 3.11.0 (ruling:lite-official-postgres-image, 2026-10-29) is, like 3.9.0, a change for lite only: it ADDS the managed app.db.image slot
56
+ # (db/Dockerfile, written by `starci app sync --write`), narrows ruleParams.db.extensions to the extensions that image carries, and gives
57
+ # @nestjs/schedule and cron one owner, platform/jobs (R335).
58
+ # 3.13.0 (rulings lite-pgmq-bus-bullmq-jobs and lite-db-image-in-stacks, 2026-10-29) is a change for lite only: ruleParams.db.extensions
59
+ # admits pgmq, the event bus, ruleParams.db.queueOutbox is retired with the pg_cron half of R258, the lite event-bus functions replace
60
+ # event_bus_write in ruleParams.be.dbFunctions, and app.db.image moves to .starcistacks/images/db/Dockerfile (db/ keeps schema and data).
55
61
 
56
62
  # Field meaning, per slot.
57
63
  # id stable name, never changes meaning inside a major.
@@ -126,9 +132,9 @@ testValues: [unit-beside, e2e, none]
126
132
  # event bus, saga, reactors, projection, realtime, webhooks, fenced job), declared in hfs.json the same way, and the same unit
127
133
  # specs (unitRoles, logicRoles, 100 per file, @starci/jest-preset doubles, builders). What differs is ADAPTERS and DELIVERY
128
134
  # (ruling:R212/case-10): schema authority in db/migrations, the queue port on BullMQ over the Redis hfs.json
129
- # sides.be.backing declares (as in full, fed by the Postgres outbox), event-bus ports on pgmq-backed topics or in-process
130
- # delivery, cache and locks on Postgres or the declared Redis, schedules on pg_cron (+ pg_net) enqueueing through the queue
131
- # outbox, storage on S3-compatible MinIO (integrations/object-storage) and auth through integrations/passport. Realtime uses SSE or the socket.io gateway
135
+ # sides.be.backing declares (as in full, fed by the Postgres outbox), event-bus ports on pgmq topics sent in the publisher's
136
+ # transaction (ruling:lite-pgmq-bus-bullmq-jobs), cache and locks on Postgres or the declared Redis, schedules as platform/jobs ticks enqueueing through the queue
137
+ # outbox (R335), storage on S3-compatible MinIO (integrations/object-storage) and auth through integrations/passport. Realtime uses SSE or the socket.io gateway
132
138
  # in the api process, which scales horizontally and stateless (memory hub for one replica or tests; the Redis hub and @socket.io/redis-adapter when Redis is declared, else the Postgres hub and @socket.io/postgres-adapter, for N replicas; websocket only, no sticky sessions). Integration and provider sandbox contract specs are optional;
133
139
  # declared patterns owe their integration proofs (R284). Lite has no e2e world. Production uses declared docker-swarm under
134
140
  # .starcistacks/production/infra/swarm/ (owner ruling 2026-10-14); lite runs its PostgreSQL as the db service of its
@@ -205,11 +211,10 @@ crossApp: apps never import each other; shared code is a packages/<pkg> slot.
205
211
  ruleParams:
206
212
  db:
207
213
  # R259: btree_gist supplies UUID equality for the booking interval exclusion constraint.
208
- # R259: unaccent ships in the supabase/postgres image of the db service, used by Vietnamese-content search functions.
209
- extensions: [pgmq, pg_cron, pgcrypto, pg_trgm, vector, uuid-ossp, pg_net, supabase_vault, unaccent, btree_gist]
210
- # R258 DB_CRON_OWNED: THE queue outbox of a lite schema (the relay hands its unsent rows to BullMQ, ruling:R212/case-10). A pg_cron
211
- # job enqueues only by inserting one row into this table, writing only these columns, one of which is `queue`.
212
- queueOutbox: {table: public.queue, columns: [queue, payload, created_at, correlation_id]}
214
+ # R259: the extensions the db image carries (ruling:lite-official-postgres-image): the official postgres image's contrib set and pgvector
215
+ # from PGDG; unaccent serves Vietnamese-content search functions; pgmq is the event bus (ruling:lite-pgmq-bus-bullmq-jobs), installed
216
+ # from its pinned release. No pg_cron, pg_net or supabase_vault.
217
+ extensions: [pgcrypto, pg_trgm, vector, uuid-ossp, unaccent, btree_gist, pgmq]
213
218
  common:
214
219
  # HFS_SIZE_GROWTH: a file above soft may not grow against its parent commit; a new file stays within soft. Slot
215
220
  # budgets (component.tsx 300, hooks 200, modules 400) are stricter than this cap where they apply.
@@ -255,7 +260,8 @@ ruleParams:
255
260
  bull: [platform/queue]
256
261
  "@nestjs/bull": [platform/queue]
257
262
  kafkajs: [platform/event-bus] # pattern event-bus: Kafka is the only event transport
258
- "@nestjs/schedule": [] # no @Cron / @Interval / @Timeout anywhere: a schedule is a BullMQ job scheduler of a queue
263
+ "@nestjs/schedule": [platform/jobs] # R335: only the tick scheduler of platform/jobs (jobs.scheduler.ts); elsewhere a schedule is a BullMQ job scheduler of a queue
264
+ cron: [platform/jobs] # R335: CronJob of the tick scheduler
259
265
  eventemitter2: []
260
266
  setInterval: [platform/retry, platform/event-bus, platform/queue, platform/http, platform/realtime]
261
267
  setTimeout: [platform/retry, platform/event-bus, platform/queue, platform/http]
@@ -424,28 +430,36 @@ ruleParams:
424
430
  # logic roles and spec doubles are full's (owner ruling 2026-10-12 (owner:lite-production-swarm), with the Work boundary amended by ruling:R212/case-4). infraOwners: full's owners,
425
431
  # bullmq in platform/queue and ioredis/redis in integrations/cache and integrations/redis (ruling:R212/case-10: the queue is
426
432
  # Redis in both editions; a backing library is legal only when hfs.json sides.be.backing declares its service, which
427
- # HFS_BACKING_UNDECLARED checks), except Kafka, which is owned nowhere (platform/event-bus runs pgmq queues through
433
+ # HFS_BACKING_UNDECLARED checks), except Kafka, which is owned nowhere (platform/event-bus runs pgmq topics through
428
434
  # the shared EntityManager); realtime is the same socket.io gateway as full, run inside the api process, scaled horizontally and stateless (hub redis when
429
435
  # Redis is declared, else hub postgres with @socket.io/postgres-adapter; no sticky sessions).
430
436
  # R334: the database functions code may call through platform/database's callDbFunction, advisoryXactLock and notify helpers,
431
- # each {name, reason}; the canon lists the pg built-ins it uses itself, and an app adds its own in hfs.json sides.be.dbFunctions.
437
+ # each {name, reason}; a pg built-in is listed bare (called in pg_catalog), every other function schema-qualified (public.job_claim),
438
+ # and an app adds its own, schema-qualified, in hfs.json sides.be.dbFunctions.
432
439
  dbFunctions:
433
440
  - {name: pg_advisory_xact_lock, reason: "advisoryXactLock: a transaction-scoped lock TypeORM has no API for"}
434
441
  - {name: hashtextextended, reason: "advisoryXactLock: the 64-bit key of a text lock name"}
435
442
  - {name: pg_notify, reason: "notify: LISTEN/NOTIFY of the Postgres realtime hub"}
436
443
  - {name: to_regproc, reason: "callDbFunction: whether an optional database function exists"}
437
- - {name: job_claim, reason: "platform/jobs (add job, lite): the fenced claim upsert of jobs.sql"}
438
- - {name: job_advance, reason: "platform/jobs (add job, lite): the token-guarded step write of jobs.sql"}
439
- - {name: job_complete, reason: "platform/jobs (add job, lite): the token-guarded completion of jobs.sql"}
440
- - {name: job_fail, reason: "platform/jobs (add job, lite): the token-guarded failure of jobs.sql"}
441
- - {name: queue_write, reason: "platform/queue (add queue, lite): the outbox insert of queue-outbox.sql"}
442
- - {name: queue_waiting, reason: "platform/queue (add queue, lite): the relay read FOR UPDATE SKIP LOCKED of queue-outbox.sql"}
443
- - {name: queue_mark_sent, reason: "platform/queue (add queue, lite): the relay mark of queue-outbox.sql"}
444
- - {name: inbox_claim_event, reason: "platform/inbox (add webhook, lite): the conflict-safe delivery claim"}
445
- - {name: inbox_claim_client, reason: "platform/inbox (add webhook, lite): the client claim upsert returning its committed outcome"}
446
- - {name: inbox_save_client_outcome, reason: "platform/inbox (add webhook, lite): the saved client outcome"}
447
- - {name: inbox_sweep_clients, reason: "platform/inbox (add webhook, lite): the bounded retention sweep FOR UPDATE SKIP LOCKED"}
448
- - {name: event_bus_write, reason: "platform/event-bus (add webhook, lite): the typed envelope insert of the outbox"}
444
+ - {name: gen_random_uuid, reason: "callDbFunction: a random uuid from the database (PG13+ core, pg_catalog) where a token is minted in the transaction"}
445
+ - {name: public.job_claim, reason: "platform/jobs (add job, lite): the fenced claim upsert of jobs.sql"}
446
+ - {name: public.job_advance, reason: "platform/jobs (add job, lite): the token-guarded step write of jobs.sql"}
447
+ - {name: public.job_complete, reason: "platform/jobs (add job, lite): the token-guarded completion of jobs.sql"}
448
+ - {name: public.job_fail, reason: "platform/jobs (add job, lite): the token-guarded failure of jobs.sql"}
449
+ - {name: public.job_tick_claim, reason: "platform/jobs (add job, lite): the once-per-minute claim of a scheduler tick, jobs.sql (R335)"}
450
+ - {name: public.queue_write, reason: "platform/queue (add queue, lite): the outbox insert of queue-outbox.sql"}
451
+ - {name: public.queue_waiting, reason: "platform/queue (add queue, lite): the relay read FOR UPDATE SKIP LOCKED of queue-outbox.sql"}
452
+ - {name: public.queue_mark_sent, reason: "platform/queue (add queue, lite): the relay mark of queue-outbox.sql"}
453
+ - {name: public.inbox_claim_event, reason: "platform/inbox (add webhook, lite): the conflict-safe delivery claim"}
454
+ - {name: public.inbox_claim_client, reason: "platform/inbox (add webhook, lite): the client claim upsert returning its committed outcome"}
455
+ - {name: public.inbox_save_client_outcome, reason: "platform/inbox (add webhook, lite): the saved client outcome"}
456
+ - {name: public.inbox_sweep_clients, reason: "platform/inbox (add webhook, lite): the bounded retention sweep FOR UPDATE SKIP LOCKED"}
457
+ - {name: public.event_bus_send, reason: "platform/event-bus (add webhook, lite): the send of a typed envelope to its pgmq topic in the publisher's transaction"}
458
+ - {name: public.event_bus_read, reason: "platform/event-bus (add webhook, lite): a consumer's pgmq read with a visibility timeout"}
459
+ - {name: public.event_bus_archive, reason: "platform/event-bus (add webhook, lite): the archive of a handled message"}
460
+ - {name: public.event_bus_bury, reason: "platform/event-bus (add webhook, lite): the move of an exhausted or malformed message to its consumer's dead-letter queue"}
461
+ - {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
462
+ - {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
449
463
 
450
464
  lite:
451
465
  schemaAuthority: sql
@@ -861,10 +875,10 @@ slots:
861
875
  "<env>/runtime/{config,env}/**", "<env>/runtime/env/KEYS.md", "<env>/secrets/<slug>.enc", "<env>/secrets/.gitkeep", "<env>/seeds/**"]
862
876
  forbids: ["<env>/runtime/files/**", "**/*.enc outside <env>/secrets/", DESIGN.md, deployment.json, "k8s/ at root"]
863
877
  # Owner ruling 2026-10-14: lite declares docker-swarm production, app runtime config, sealed custody and seed data.
864
- # The db service supplies the database and broker (pgmq, pg_cron); production has no Compose or host cron.
878
+ # The db service supplies the database; schedules are platform/jobs ticks (R335); production has no Compose or host cron.
865
879
  lite:
866
880
  requires: [application-stacks.yaml]
867
- allows: [application-stacks.yaml, "<env>/README.md", "<env>/environment.json", "<env>/infra/swarm/**", "<env>/infra/metadata.json",
881
+ allows: [application-stacks.yaml, images/db/Dockerfile, "<env>/README.md", "<env>/environment.json", "<env>/infra/swarm/**", "<env>/infra/metadata.json",
868
882
  "<env>/runtime/{config,env}/**", "<env>/runtime/env/KEYS.md", "<env>/secrets/<slug>.enc", "<env>/secrets/.gitkeep", "<env>/seeds/**"]
869
883
  forbids: ["<env>/runtime/files/**", "**/*.enc outside <env>/secrets/", DESIGN.md, deployment.json, "k8s/ at root"]
870
884
  rules: [HFS_STACKS_SHAPE, HFS_STACKS_IN_SIDE, HFS_PLAINTEXT_SECRET, HFS_IDENTITY_CUSTODY]
@@ -925,7 +939,7 @@ slots:
925
939
  rules: [HFS_PLAINTEXT_SECRET]
926
940
 
927
941
  # ----- app root: the database of a lite app (ruling:lite-self-hosted-postgres-passport-minio): a self-hosted PostgreSQL in the app's
928
- # stack (the `db` service, image supabase/postgres for pg_cron, pg_net, pgmq, vector, unaccent and pgcrypto), migrated by
942
+ # stack (the `db` service, built from the managed .starcistacks/images/db/Dockerfile: the official postgres image plus pgvector and pgmq, ruling:lite-db-image-in-stacks), migrated by
929
943
  # `starci app db migrate --url-env <NAME>`. A full app keeps its TypeORM migrations under be/ and has no such folder.
930
944
  - id: app.db
931
945
  profiles: [app]
@@ -975,12 +989,29 @@ slots:
975
989
  tier: none
976
990
  tests: none
977
991
  since: 3.9.0
992
+ - id: app.db.image
993
+ profiles: [app]
994
+ editions: [lite]
995
+ # The managed image of the db service (templates/app/db-image, rulings lite-official-postgres-image and lite-db-image-in-stacks): FROM
996
+ # the digest-pinned official postgres image plus postgresql-17-pgvector from PGDG and the sha256-verified pgmq release. It lives with
997
+ # the stacks; the stack's db service, the local db container, the scratch database of `starci app db drift` and the squash replay
998
+ # all build it.
999
+ path: ".starcistacks/images/db/Dockerfile"
1000
+ presence: required
1001
+ tracked: tracked
1002
+ managedBy: db-image
1003
+ tier: none
1004
+ tests: none
1005
+ since: 3.11.0
1006
+ rules: [HFS_MANAGED_FILE_DRIFT]
978
1007
  - id: app.db.bootstrap
979
1008
  profiles: [app]
980
1009
  editions: [lite]
981
- # The managed pre-migration bootstrap (templates/app/db-bootstrap): the auth, storage and extensions schemas, auth.users with the
982
- # GoTrue column set, auth.uid/role/jwt stubs, the NOLOGIN browser roles and the realtime publication, so migrations written for a
983
- # hosted Supabase apply to the self-hosted db service. `starci app db migrate` applies it before the first migration of an empty database.
1010
+ # The managed pre-migration bootstrap (templates/app/db-bootstrap): the extensions schema every allowed extension is created in, the
1011
+ # auth and storage schemas, auth.users with the GoTrue column set, auth.uid/role/jwt stubs and the NOLOGIN browser roles, so migrations
1012
+ # written for a hosted Supabase apply to the db service's official postgres image (nothing else of Supabase: no supabase_admin or
1013
+ # realtime publication). `starci app db migrate` applies it before the first migration of an empty database; sync renders all of it
1014
+ # but the one marked section `starci app db baseline --squash` writes (the source's app roles and the grants a per-database dump loses).
984
1015
  path: "db/bootstrap.sql"
985
1016
  presence: required
986
1017
  tracked: tracked
@@ -1765,7 +1796,7 @@ slots:
1765
1796
  - id: be.platform.event-bus
1766
1797
  since: 3.1.0
1767
1798
  profiles: [be]
1768
- editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite implements the event ports with outbox and pgmq; full uses its Kafka adapter."
1799
+ editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite implements the event ports on pgmq (ruling:lite-pgmq-bus-bullmq-jobs); full uses its Kafka adapter."
1769
1800
  path: "src/modules/platform/event-bus/"
1770
1801
  presence: opt-in
1771
1802
  pattern: event-bus # the ONE durable event transport: transactional outbox, relay, Kafka, inbox, retry and dead letter (knowledge/patterns/be/event-bus.yaml)
@@ -1774,10 +1805,10 @@ slots:
1774
1805
  owner: true
1775
1806
  lite:
1776
1807
  why: >-
1777
- the same EventPublisher and EventConsumer ports on pgmq queues: the outbox relay sends each committed row to the
1778
- pgmq queue of every consumer group of its topic through the shared EntityManager, a consumer reads with a visibility
1779
- timeout, retries by letting it lapse and archives the message on dead letter (read_ct over the budget); a single api may
1780
- deliver in process. No Kafka (knowledge/patterns/be/event-bus.yaml liteAdapter)
1808
+ the EventBus and EventConsumer ports on pgmq (ruling:lite-pgmq-bus-bullmq-jobs): publish sends the envelope to its
1809
+ topic in the publisher's transaction, so no outbox or relay runs; each consumer group reads its own queue bound to the
1810
+ event's name with a visibility timeout, archives a handled message, lets a failed one reappear and buries one past its
1811
+ maxDeliveries in its __dead queue (BE-EVENT-BUS-6). Jobs stay on BullMQ (platform/queue). No Kafka (knowledge/patterns/be/event-bus.yaml liteAdapter)
1781
1812
  requires: [index.ts, event-bus.module.ts, event-bus.port.ts, event-bus.contracts.ts, event-bus.service.ts, event-bus.service.spec.ts]
1782
1813
  tests: unit-beside
1783
1814
  coverage: required
@@ -1800,7 +1831,7 @@ slots:
1800
1831
  the same QueueOutbox and QueueWorkerRegistry ports and full's BullMQ adapter on the declared Redis (backing: [redis]): the
1801
1832
  relay adds each committed outbox row with the outbox id as the job id, workers retry with exponential backoff and keep a
1802
1833
  job that exhausts its attempts as failed (the dead letter); the outbox table comes from a db migration, and a
1803
- schedule is a BullMQ job scheduler or a pg_cron tick that inserts one outbox row (knowledge/patterns/be/queues.yaml liteAdapter)
1834
+ schedule is a BullMQ job scheduler or a platform/jobs tick that writes one outbox row, R335 (knowledge/patterns/be/queues.yaml liteAdapter)
1804
1835
  requires: [index.ts, queue.module.ts, queue.port.ts, queue.contracts.ts, queue-outbox.service.ts, queue-outbox.service.spec.ts]
1805
1836
  tests: unit-beside
1806
1837
  coverage: required
@@ -288,19 +288,21 @@ files:
288
288
  role: "The consumed event's e2e flow; titles event-bus/<scenario>: ... prove the pattern."
289
289
  spec: event-bus/atomic-with-transaction, event-bus/relay-only-after-commit, event-bus/redelivery-then-dead-letter, event-bus/duplicate-delivery-is-a-noop
290
290
  rules: [BE_PATTERN_SPEC_MISSING]
291
- # Edition lite (R212): the same ports and rules, a self-hosted Postgres adapter behind them. ruling:R212/case-10 moved the lite queue to Redis
292
- # because full's queue is Redis; full's event bus is Kafka, so the lite event-bus adapter below is unchanged by that ruling.
291
+ # Edition lite (R212): the same ports and rules, a self-hosted Postgres adapter behind them. ruling:lite-pgmq-bus-bullmq-jobs makes pgmq the
292
+ # lite event bus and keeps jobs on BullMQ (platform/queue).
293
293
  liteAdapter:
294
294
  summary: >-
295
- Under lite the event bus keeps typed events, the vendored contract, publish(event, tx, correlationId) in the domain transaction, the outbox,
296
- the inbox and thin consumers; Kafka is replaced by pgmq-backed topics, or by in-process delivery after commit for a back end
297
- with a single api app. Each consumer group of a topic has its own pgmq queue `<service>__<event>__<group>`, created by a
298
- db migration, named by a literal `<GROUP>_TOPIC_QUEUE` constant under platform/event-bus (its owner, R258) and
299
- granted only to the application role, so each group keeps its own cursor and dead letter.
295
+ Under lite the event bus keeps typed events, the vendored contract, publish(event, tx, correlationId) in the domain transaction,
296
+ the inbox and thin consumers; Kafka is replaced by pgmq topics in the db service. Publish sends the envelope to its topic
297
+ (the event name) in the publisher's transaction, so no outbox or relay stands between them. Each consumer of an event has its
298
+ own pgmq queue `<topic>__<consumer>` and dead-letter queue `<topic>__<consumer>__dead`, created and bound to the event's name
299
+ by a db migration and named by a literal `<TOPIC>_<CONSUMER>_TOPIC_QUEUE` constant under platform/event-bus (its owner, R258);
300
+ every payload carries a business dedupeKey the consumer inbox claims, and subscriptions are static (R336); the application
301
+ role reaches pgmq only through the security-definer event_bus_* functions of the migration (contexts/code/be/event-bus.md BE-EVENT-BUS-6).
300
302
  ports:
301
- - {port: EventPublisher (platform/event-bus), adapter: the same outbox row in the domain transaction}
302
- - {port: relay, adapter: "fans each committed event out with pgmq.send to one queue per consumer group of its topic, marking the row in the same transaction"}
303
- - {port: EventConsumer (platform/event-bus), adapter: "pgmq.read with a visibility timeout, the inbox claim on the event id, pgmq.delete on success, pgmq.archive as the dead letter"}
303
+ - {port: EventBus.publish (platform/event-bus), adapter: "event_bus_send: pgmq.send_topic of the envelope in the domain transaction"}
304
+ - {port: EventConsumer (platform/event-bus), adapter: "event_bus_read with a visibility timeout, the inbox claim on (consumer, dedupeKey) in the dispatched command, event_bus_archive on success, a failed delivery left to reappear, event_bus_bury into <queue>__dead past maxDeliveries (default 5)"}
305
+ - {port: EventBus.deadLetters and requeue (platform/event-bus), adapter: "event_bus_dead_letters and event_bus_requeue over a consumer's dead-letter queue"}
304
306
  forbids: [kafkajs]
305
307
  proof: >-
306
308
  Unit specs as full; declaring event-bus requires integration specs titled `event-bus/<scenario>: ...` for every declared
@@ -258,7 +258,7 @@ liteAdapter:
258
258
  idempotency pattern with its required proof scenarios.
259
259
  ports:
260
260
  - {port: Inbox, adapter: PostgresInbox through the caller's active EntityManager transaction}
261
- - {port: receipt retention, adapter: a fenced job delivered by the BullMQ queue on the declared Redis and scheduled by a pg_cron tick that enqueues through the queue outbox}
261
+ - {port: receipt retention, adapter: a fenced job delivered by the BullMQ queue on the declared Redis and scheduled by a platform/jobs tick (R335) that enqueues through the queue outbox}
262
262
  proof: >-
263
263
  The same inbox and fingerprint unit specs apply; declaring idempotency requires both client receipt scenarios in
264
264
  lite's local db integration lane.
@@ -287,8 +287,11 @@ liteAdapter:
287
287
  ports:
288
288
  - {port: job claim (platform/jobs), adapter: "the claim and guarded writes as the job_claim, job_advance, job_complete and job_fail functions of the jobs migration in db/migrations, called through callDbFunction of platform/database (R334)"}
289
289
  - {port: processor delivery, adapter: "full's BullMQ worker (attempts and exponential backoff from the queue policy, failed jobs kept as the dead letter)"}
290
+ - {port: tick scheduler (platform/jobs), adapter: "JOB_TICKS of job-ticks.contracts.ts as data and JobTickService (job-tick.service.ts, composed with ScheduleModule.forRoot() by the lite jobs.module.ts): one CronJob per tick in SchedulerRegistry, each firing taking advisoryXactLock and claiming the tick's UTC minute through the job_tick_claim function of the jobs migration before it writes the queue outbox (R335, contexts/code/be/jobs.md BE-JOBS-6)"}
290
291
  proof: >-
291
292
  Unit specs as full (job-claim.service.spec.ts); declaring fenced-job requires integration specs titled
292
293
  `fenced-job/<scenario>: ...` for every declared scenario on the local db service (R284, which runs no container of its
293
294
  own): the claim, the fencing token and the guarded writes are Postgres facts, driven there by the real runner without BullMQ.
295
+ The tick scheduler's unit spec (job-tick.service.spec.ts) proves the lock, the minute claim and that a replica that comes
296
+ second enqueues nothing.
294
297
  see: [knowledge/patterns/be/queues.yaml]
@@ -78,18 +78,19 @@ rules:
78
78
  manual: []
79
79
  relatedExamples: [queue]
80
80
  - id: BE-QUEUES-3
81
- title: A schedule is a BullMQ job scheduler declared with its queue, or a pg_cron tick that writes the outbox
81
+ title: A schedule is a BullMQ job scheduler declared with its queue, or a platform/jobs tick that writes the outbox
82
82
  kind: mandatory
83
- hfsRules: [R90, R258]
83
+ hfsRules: [R90, R335]
84
84
  requirement: |-
85
85
  A recurring task is a job scheduler (`upsertJobScheduler`) that the queue module declares in its `schedulers` list and
86
- `platform/queue` registers at boot, or, in a lite app, a uniquely named pg_cron job of a migration whose command
87
- (directly or through one `*_tick` function) only inserts one row of a queue constant into the queue outbox table of
88
- `ruleParams.db.queueOutbox` (`public.queue`), which the relay hands to BullMQ, or by calling the enqueue function that the app declares with its own outbox table in `hfs.json` `sides.be.queueOutbox` (redis declared in `sides.be.backing`; the function's active definition only inserts into that table); a tick has exactly one of the two owners. A job name is scheduled once while it is active: a later migration that replaces a tick's command calls `cron.unschedule` of the name first. Its job is processed by the same
89
- `features/jobs/<job>/<job>.processor.ts` as any enqueued job. `@nestjs/schedule`, `@Cron`, `@Interval`, `@Timeout` and
90
- `setInterval` for work are not used anywhere.
86
+ `platform/queue` registers at boot, or, in a lite app, a tick of `platform/jobs` (BE-JOBS-6, R335): `JOB_TICKS` of
87
+ `job-ticks.contracts.ts` lists it as data and `JobTickService` fires it on every replica, where each firing takes
88
+ `advisoryXactLock(tx, "tick:<name>")`, claims the tick's UTC minute through `job_tick_claim` and only then writes one row of
89
+ the tick's queue into the queue outbox, which the relay hands to BullMQ. Its job is processed by the same
90
+ `features/jobs/<job>/<job>.processor.ts` as any enqueued job. `@nestjs/schedule` and `cron` are imported only by that
91
+ scheduler, and `@Cron`, `@Interval`, `@Timeout` and `setInterval` for work are not used anywhere.
91
92
  rationale: |-
92
- One scheduler in Redis, or one pg_cron job in the database, fires once for the whole deployment, where cron in a replicated
93
+ One scheduler in Redis, or one minute claim in the database, fires once for the whole deployment, where cron in a replicated
93
94
  process fires once per replica.
94
95
  cases:
95
96
  - id: case-1
@@ -97,12 +98,11 @@ rules:
97
98
  write: 'order-expiry.queue.ts declares scheduler { every: 5 minutes } for the expire-orders job; the processor dispatches ExpireOrdersCommand.'
98
99
  - id: case-2
99
100
  when: A lite app sends booking reminders every fifteen minutes
100
- write: "A migration declares private.booking_reminder_tick() as one insert into public.queue (queue, payload, created_at) values ('booking_reminder', jsonb_build_object('tick', true), now()) and schedules it with cron.schedule('booking_reminder_tick', '*/15 * * * *', ...); the send-reminder processor is the queue's."
101
+ write: "platform/jobs declares a booking_reminder tick every fifteen minutes (BE-JOBS-6): each firing claims its slot and writes one booking_reminder row through the queue outbox; the send-reminder processor is the queue's."
101
102
  verification:
102
103
  automated:
103
104
  - BE_INFRA_OWNER
104
105
  - DB_PGMQ_DECLARED
105
- - DB_CRON_OWNED
106
106
  manual:
107
107
  - Confirm the cadence and the job's idempotence.
108
108
  relatedExamples: [queue]
@@ -276,19 +276,19 @@ liteAdapter:
276
276
  its append-only file (WAITAOF) and still holds it, so a job Redis loses inside the at most one second fsync window leaves
277
277
  its row waiting and is added again; that is why a lite queue does not lose a job. Without a declared Redis there is no queue
278
278
  pattern: the work is a direct call inside the request, or a declared replicated-job running a cli command. pgmq
279
- is not a queue transport. `starci app add queue|job` writes this adapter in a lite app, with the outbox and job
279
+ is the event bus, not a queue transport (ruling:lite-pgmq-bus-bullmq-jobs). `starci app add queue|job` writes this adapter in a lite app, with the outbox and job
280
280
  tables as one db migration, the Redis declaration and the persisted Redis of every docker-swarm environment.
281
281
  ports:
282
282
  - {port: QueueOutbox (platform/queue), adapter: "the outbox row written through the caller's tx by queue_write, and the relay's queue_waiting and queue_mark_sent, functions of the queue migration in db/migrations called through callDbFunction of platform/database (R334)"}
283
283
  - {port: queue transport (queue-transport.port.ts), adapter: "full's bullmq-queue-transport.client.ts over the declared Redis (host, port, db and the password read from its sealed secret file), plus persist(jobs): WAITAOF 1 0 on each queue's connection, then each job's state, refusing with QUEUE_NOT_PERSISTED or QUEUE_JOB_LOST (errors/queue.error.ts)"}
284
284
  - {port: outbox relay (queue-relay.service.ts), adapter: "full's relay, which calls persist after the adds and before it marks the rows sent, so a refusal rolls the mark back and the next pass adds the rows again"}
285
285
  - {port: QueueWorkerRegistry (platform/queue), adapter: "full's BullMQ worker per queue: attempts and exponential backoff from queue.policy.ts, failed jobs kept as the dead letter"}
286
- - {port: schedulers, adapter: "a BullMQ job scheduler declared with its queue, as full, or a pg_cron job in a db migration whose tick inserts one row of the queue constant into the queue outbox"}
287
- forbids: [kafkajs, "@nestjs/schedule"]
286
+ - {port: schedulers, adapter: "a BullMQ job scheduler declared with its queue, as full, or a platform/jobs tick (JobTickService, R335) that writes one row of the tick's queue into the queue outbox under its lock and minute claim"}
287
+ forbids: [kafkajs]
288
288
  proof: >-
289
289
  Unit specs as full (queue-outbox, relay and worker services, the BullMQ client against a mocked factory); declaring queue
290
290
  requires integration specs titled `queue/<scenario>: ...` for every declared scenario on the local db service (R284,
291
- which runs no container of its own): the outbox, its commit boundary, the relay identity and the pg_cron tick are Postgres
291
+ which runs no container of its own): the outbox, its commit boundary, the relay identity and the tick's minute claim are Postgres
292
292
  facts proved there with the real outbox writer and relay, and the queue transport records what the relay hands to BullMQ
293
293
  (the job id is the outbox id). BullMQ itself is covered by the unit spec of the BullMQ client, which also proves the
294
294
  persist refusals; the relay's unit spec proves a refused persist leaves the rows waiting.
@@ -158,7 +158,7 @@ files:
158
158
  liteAdapter:
159
159
  summary: >-
160
160
  Under lite a reactor is unchanged: a thin consumer of another context's events that dispatches one command. Its deliveries
161
- come from the lite event bus (its own pgmq queue per consumer group).
161
+ come from the lite event bus (its own pgmq queue per consumer group, bound to the event's topic).
162
162
  ports:
163
163
  - {port: EventConsumer delivery, adapter: "the reactor's pgmq queue (see event-bus.yaml liteAdapter)"}
164
164
  proof: >-
@@ -670,7 +670,7 @@ files:
670
670
  optional: true
671
671
  - path: db/migrations/<timestamp>_realtime-events.sql
672
672
  slot: repo.side-root-forbidden
673
- role: Lite edition migration (created with `npm run db:new`) of the same two tables, forced row level security for app_be, and the pg_cron tick of the retention queue.
673
+ role: Lite edition migration (created with `npm run db:new`) of the same two tables, forced row level security for app_be; the retention queue is fired by a platform/jobs tick (R335).
674
674
  optional: true
675
675
  - path: src/features/reactors/<name>/transport/message/<event>.consumer.ts
676
676
  slot: be.transport.message
@@ -1827,17 +1827,6 @@ DB_BASELINE_SHAPE:
1827
1827
 
1828
1828
 
1829
1829
 
1830
- DB_CRON_OWNED:
1831
- title: "A uniquely owned migration cron job only enqueues through the queue outbox"
1832
- title_vi: "Lịch pg_cron thiếu chủ sở hữu hoặc thực hiện nghiệp vụ"
1833
- meaning_vi: "Tên lịch bị trùng, hằng hàng đợi hoặc hàm tick không tồn tại, lệnh thực hiện DML nghiệp vụ hay HTTP thay vì chỉ chèn một dòng của hằng hàng đợi vào bảng outbox của ruleParams.db.queueOutbox (chỉ các cột đã khai báo), lịch đang chạy vẫn gọi pgmq.send, hoặc cùng tick còn được lập lịch ở ứng dụng."
1834
- causes_vi:
1835
- - "Vi phạm R258: pg_cron chỉ đưa việc vào hàng đợi qua outbox, mỗi tick có đúng một nơi lập lịch và được hủy lịch khi loại bỏ."
1836
- - "Lịch đang chạy (hoặc tick hiện tại của nó) vẫn gửi bằng pgmq.send; pgmq.send chỉ hợp lệ trong thân gốc của lịch cũ."
1837
- nextStep_vi: "Đặt lịch trong migration với tên duy nhất, chèn đúng một dòng của hằng hàng đợi vào outbox hàng đợi (trực tiếp hoặc qua một hàm *_tick chỉ làm việc đó), bỏ lịch trùng và gọi cron.unschedule khi xóa tick."
1838
- owner: op-retry
1839
- kind: check-finding
1840
-
1841
1830
  DB_DEFINER_REVOKE:
1842
1831
  title: "Exposed security definer functions revoke browser execution"
1843
1832
  title_vi: "Function security definer chưa thu hồi quyền trình duyệt"
@@ -1909,14 +1898,14 @@ DB_MIGRATION_SHAPE:
1909
1898
  kind: check-finding
1910
1899
 
1911
1900
  DB_PGMQ_DECLARED:
1912
- title: "A queue constant keeps a pgmq queue, a pgmq queue has no event-bus owner, or a pgmq object is declared or granted unsafely"
1913
- title_vi: "Hằng hàng đợi còn hàng đợi pgmq, hàng đợi pgmq không có chủ event-bus, hoặc đối tượng pgmq khai báo hay cấp quyền sai"
1914
- meaning_vi: "Hàng đợi là BullMQ trên Redis (ruling:R212/case-10), nhưng các migration đang hoạt động vẫn để lại một hàng đợi pgmq trùng tên một hằng hàng đợi trong modules/queues, hoặc một hàng đợi pgmq không được hằng literal *_TOPIC_QUEUE nào trong platform/event-bus đặt tên (hay một hằng như vậy không có hàng đợi pgmq); hoặc pgmq.create không có tên literal trong migration, tên hằng không phải literal, hay quyền trên đối tượng pgmq vượt vai trò ứng dụng."
1901
+ title: "A queue constant keeps a pgmq queue, a pgmq queue has no event-bus owner, or a pgmq queue, binding or grant is declared unsafely"
1902
+ title_vi: "Hằng hàng đợi còn hàng đợi pgmq, hàng đợi pgmq không có chủ event-bus, hoặc hàng đợi, liên kết topic hay quyền pgmq khai báo sai"
1903
+ meaning_vi: "pgmq là event bus và job chạy trên BullMQ (ruling:lite-pgmq-bus-bullmq-jobs), nhưng các migration đang hoạt động vẫn để lại một hàng đợi pgmq trùng tên một hằng hàng đợi trong modules/queues, hoặc một hàng đợi pgmq không được hằng literal *_TOPIC_QUEUE nào trong platform/event-bus đặt tên (hay một hằng như vậy không có hàng đợi pgmq); hoặc pgmq.create hay pgmq.bind_topic không có tên literal trong migration, liên kết một hàng đợi chưa được tạo, tên hằng không phải literal, hay quyền trên đối tượng pgmq vượt vai trò ứng dụng."
1915
1904
  causes_vi:
1916
- - "Vi phạm R258: hàng đợi của app vẫn chạy trên Supabase Queues (pgmq) thay vì BullMQ trên Redis."
1917
- - "Một hàng đợi pgmq bị bỏ lại hoặc đổi tên mà không có chủ event-bus."
1905
+ - "Vi phạm R258: một job của app chạy trên pgmq thay vì BullMQ trên Redis."
1906
+ - "Một hàng đợi pgmq bị bỏ lại hoặc đổi tên mà không có chủ event-bus, hoặc một liên kết topic trỏ tới hàng đợi chưa tạo."
1918
1907
  - "Migration, mã nguồn hoặc quyền truy cập khai báo đối tượng pgmq ngoài migration tĩnh hoặc cho vai trò khác vai trò ứng dụng."
1919
- nextStep_vi: "Chuyển hàng đợi sang BullMQ trên Redis đã khai báo, rồi xóa hàng đợi pgmq bằng pgmq.drop_queue trong một migration mới; hàng đợi topic của event bus được đặt tên bằng một hằng literal *_TOPIC_QUEUE trong platform/event-bus; chỉ cấp quyền trên q_<queue>, a_<queue> cho vai trò ứng dụng."
1908
+ nextStep_vi: "Chuyển job sang BullMQ trên Redis đã khai báo, rồi xóa hàng đợi pgmq bằng pgmq.drop_queue trong một migration mới; hàng đợi của một nhóm consumer được đặt tên bằng một hằng literal *_TOPIC_QUEUE trong platform/event-bus, tạo bằng pgmq.create rồi liên kết với tên event bằng pgmq.bind_topic trong cùng migration; vai trò ứng dụng đi qua các hàm event_bus_* thay vì nhận quyền trên schema pgmq."
1920
1909
  owner: op-retry
1921
1910
  kind: check-finding
1922
1911
 
@@ -3732,7 +3721,7 @@ HFS_HOST_CRON:
3732
3721
  meaning_vi: "Tệp triển khai hoặc script tại <path> khai báo crontab, cron.d, systemd timer, @reboot hoặc at; lịch chạy phải do ứng dụng sở hữu."
3733
3722
  causes_vi:
3734
3723
  - "Vi phạm R250: cài lịch chạy ngoài scheduler của ứng dụng hoặc replicated-job đã khai báo."
3735
- nextStep_vi: "Chuyển tác vụ sang scheduler hàng đợi của back end, một tick pg_cron enqueue qua outbox hàng đợi hoặc replicated-job đã khai báo và gọi lệnh CLI của ứng dụng."
3724
+ nextStep_vi: "Chuyển tác vụ sang scheduler hàng đợi của back end, một tick của platform/jobs (R335) enqueue qua outbox hàng đợi hoặc replicated-job đã khai báo và gọi lệnh CLI của ứng dụng."
3736
3725
  owner: op-retry
3737
3726
  kind: check-finding
3738
3727
 
@@ -5600,3 +5589,24 @@ BE_PERSISTENCE_NO_RAW_SQL:
5600
5589
  nextStep_vi: "Khai entity ở persistence/entities/<table>.entity.ts và đọc/ghi bằng EntityManager hoặc createQueryBuilder với tham số có tên; hàm pg/SECURITY DEFINER không diễn đạt được thì khai vào rawSqlFunctions kèm lý do."
5601
5590
  owner: op-retry
5602
5591
  kind: check-finding
5592
+
5593
+ BE_EVENT_BUS_LITE_SHAPE:
5594
+ title: "A lite event payload without dedupeKey, or a subscription that is not static"
5595
+ title_vi: "Payload event của bản lite thiếu dedupeKey, hoặc đăng ký consumer không tĩnh"
5596
+ meaning_vi: "File `<path>` khai một event (lớp kế thừa BaseEvent trong modules/events) mà kiểu payload không có `dedupeKey`, hoặc gọi `subscribe` với tên hàng đợi không phải hằng `*_TOPIC_QUEUE` import từ `@modules/platform/event-bus`, hay với options không phải object literal của giá trị literal."
5597
+ causes_vi:
5598
+ - "Vi phạm R336: event bus pgmq giao ít nhất một lần và không theo thứ tự, nên inbox của consumer claim (consumer, dedupeKey); thiếu dedupeKey thì không khử trùng được."
5599
+ - "Một đăng ký tính tên hàng đợi lúc chạy, nên không ai thấy được nó trong hằng và migration (R258)."
5600
+ nextStep_vi: "Thêm `readonly dedupeKey: string` (khoá nghiệp vụ, như id đơn hàng) vào kiểu payload hoặc kế thừa `EventPayload`; khai hằng `<TOPIC>_<CONSUMER>_TOPIC_QUEUE` trong platform/event-bus, tạo hàng đợi, hàng đợi `__dead` và bind_topic trong migration, rồi subscribe bằng hằng đó."
5601
+ owner: op-retry
5602
+ kind: check-finding
5603
+
5604
+ BE_JOBS_SCHEDULER_OWNER:
5605
+ title: "A schedule outside the platform/jobs tick scheduler"
5606
+ title_vi: "Lịch chạy định kỳ nằm ngoài bộ lập lịch tick của platform/jobs"
5607
+ meaning_vi: "File `<path>` import @nestjs/schedule hoặc cron ngoài job-tick.service.ts và jobs.module.ts của platform/jobs, dùng decorator Cron/Interval/Timeout, hoặc JobTickService không giữ advisoryXactLock và job_tick_claim trước khi ghi outbox."
5608
+ causes_vi:
5609
+ - "Vi phạm R335: việc định kỳ là tick của platform/jobs (JOB_TICKS), mỗi phút chỉ một replica enqueue nhờ khoá advisory và claim phút trong DB."
5610
+ nextStep_vi: "Khai tick vào JOB_TICKS của platform/jobs/job-ticks.contracts.ts (name, cron, timeZone UTC, queue, payload) và xoá lịch tự viết; giữ advisoryXactLock và job_tick_claim trong JobTickService."
5611
+ owner: op-retry
5612
+ kind: check-finding
@@ -85,7 +85,7 @@ properties:
85
85
  command: {type: string, minLength: 1}
86
86
  backing:
87
87
  description: >-
88
- The self-hosted database of a lite app: its db component (image supabase/postgres), a Docker container in
88
+ The self-hosted database of a lite app: its db component (the image its managed .starcistacks/images/db/Dockerfile builds), a Docker container in
89
89
  development and a Swarm service in production. Every other dependency is a Docker component too.
90
90
  type: object
91
91
  additionalProperties:
@@ -855,5 +855,7 @@ $defs:
855
855
  - {type: integer, minimum: 1}
856
856
  - {type: string, pattern: '^[1-9][0-9]*$'}
857
857
  where: {type: string, minLength: 1}
858
- recipientPolicy: {type: string, minLength: 1, description: "Which age recipient the envelope is encrypted to: the one shared master recipient every project uses."}
858
+ member: {type: string, pattern: '^[a-z0-9][a-z0-9-]*$', description: "The provider member holding this key (`secrets/<member>`, the slug of where); with key, the entry declares one key of it."}
859
+ key: {type: string, pattern: '^[A-Z][A-Z0-9_]*$', description: "The one key of the member this entry declares; materialized as its own file, read by the app as <KEY>_FILE."}
860
+ recipientPolicy: {type: string, minLength: 1, description: "Which age recipients the envelope is encrypted to: its environment's rule of the app's managed .sops.yaml."}
859
861
  keyCustody: {type: string, minLength: 1, description: "Where the shared decrypting identity is held; docs/host-secrets.md owns host custody and its execution prerequisites."}
@@ -329,7 +329,10 @@ const checkTrackedCustody = ({ repo, root, add }) => {
329
329
  // infra/** is tracked source, but a value file inside it (.env*, *.env, *.key, *.pem, *.tfvars)
330
330
  // is plaintext custody (inc-5360513a96b3: infra/compose/.env.generated was trackable).
331
331
  const infraValue = parts.length > 3 && parts[2] === 'infra' && isInfrastructureValueFile(base) && !base.endsWith('.enc') && !base.endsWith('.example');
332
- if ((custodyArea && !base.endsWith('.enc') && !['KEYS.md', '.gitkeep'].includes(base)) || infraValue)
332
+ // An app's sealed member is `<env>/secrets/<member>.enc.yaml` (ruling app-secrets-one-standard, scripts/lib/sealed-member.mjs);
333
+ // runtime custody keeps `<name>.<fmt>.enc`.
334
+ const appMember = parts.length === 4 && parts[2] === 'secrets' && base.endsWith('.enc.yaml');
335
+ if ((custodyArea && !base.endsWith('.enc') && !appMember && !['KEYS.md', '.gitkeep'].includes(base)) || infraValue)
333
336
  add('refuse', 'STACKS_PLAINTEXT_TRACKED', file, /\.enc\.(?:ya?ml|json|env)$/u.test(base)
334
337
  ? `${base} is named the wrong way round: a custody file is <name>.<fmt>.enc (rename it to ${base.replace(/\.enc\.(ya?ml|json|env)$/u, '.$1.enc')}) and every read states the format (scripts/api/sops/exec-env.mjs)`
335
338
  : 'a custody member is tracked in plaintext; untrack it (git rm --cached), rotate the value and commit only its .enc twin');
@@ -363,7 +366,8 @@ const checkIgnoreRules = ({ repo, root, add }) => {
363
366
  }
364
367
  };
365
368
  // Custody layout (modules/schemas/stacks-layout.yaml custody, the product repositories' secrets-guard).
366
- const envDirs = (repo, root) => fs.readdirSync(path.join(repo, root), { withFileTypes: true }).filter((entry) => entry.isDirectory());
369
+ // The images folder holds the managed db image of a lite app (slot app.db.image), never custody or an environment.
370
+ const envDirs = (repo, root) => fs.readdirSync(path.join(repo, root), { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name !== 'images');
367
371
  const checkCustodyLayout = ({ repo, own, add }) => {
368
372
  if (!own.rooted) return;
369
373
  const root = STACK_ROOT;
@@ -54,7 +54,7 @@ function diffScanner(files = [], { encText = null, keepHit = null } = {}) {
54
54
  if (raw.startsWith('+++ ')) {
55
55
  flushEnc();
56
56
  file = raw.slice(4).replace(/^b\//, '');
57
- if (/\.enc$/i.test(file)) enc = { file, line: null, added: [], findings: [] };
57
+ if (/\.enc(?:\.yaml)?$/i.test(file)) enc = { file, line: null, added: [], findings: [] };
58
58
  return;
59
59
  }
60
60
  const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { isSealedName, memberOfName } from '../lib/sealed-member.mjs';
2
3
  import { list } from '../lib/list.mjs';
3
4
  import { parseYaml } from '../../engine/yaml.mjs';
4
5
 
@@ -70,7 +71,7 @@ function entries(dir, fs) {
70
71
  export function sealedKeyNames(root, { fs }) {
71
72
  const out = [], base = path.join(root, '.starcistacks');
72
73
  for (const env of entries(base, fs).filter((entry) => entry.isDirectory())) {
73
- for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
74
+ for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && isSealedName(entry.name))) {
74
75
  const file = `.starcistacks/${env.name}/secrets/${member.name}`;
75
76
  const source = fs.readFileSync(path.join(root, file), 'utf8');
76
77
  const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
@@ -79,7 +80,7 @@ export function sealedKeyNames(root, { fs }) {
79
80
  const doc = parseYaml(source);
80
81
  keys = doc && typeof doc === 'object' && !Array.isArray(doc) ? Object.keys(doc).filter((key) => key !== 'sops' && !key.startsWith('sops_')) : [];
81
82
  }
82
- for (const key of keys) out.push({ env: env.name, member: member.name.slice(0, -4), key, file });
83
+ for (const key of keys) out.push({ env: env.name, member: memberOfName(member.name), key, file });
83
84
  }
84
85
  }
85
86
  return out;