@starci/hfs 4.13.0 → 4.15.0

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 (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +1 -1
  3. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  4. package/runtime/knowledge/hfs/rules.yaml +140 -2
  5. package/runtime/knowledge/hfs/slots.yaml +6 -2
  6. package/runtime/modules/kernel/failure-codes.yaml +5 -5
  7. package/runtime/modules/schemas/application-stacks.schema.yaml +22 -0
  8. package/runtime/scripts/hfs/declaration-shape.mjs +4 -2
  9. package/runtime/scripts/hfs/manifest-shape.d.mts +6 -0
  10. package/runtime/scripts/hfs/rule-params-shape.d.mts +5 -0
  11. package/runtime/scripts/hfs/rule-params-shape.mjs +7 -2
  12. package/runtime/scripts/hfs/slots.d.mts +2 -1
  13. package/runtime/scripts/hfs/slots.mjs +15 -5
  14. package/runtime/scripts/hfs/stack-deploy-config.d.mts +12 -0
  15. package/runtime/scripts/hfs/stack-deploy-config.mjs +22 -1
  16. package/src/stack-records-double.d.mts +3 -1
  17. package/stack/ci-key.d.mts +11 -0
  18. package/stack/ci-key.mjs +44 -0
  19. package/stack/cli.d.mts +5 -1
  20. package/stack/cli.mjs +9 -6
  21. package/stack/config.d.mts +9 -0
  22. package/stack/deploy.d.mts +4 -1
  23. package/stack/deploy.mjs +10 -5
  24. package/stack/journal.d.mts +19 -0
  25. package/stack/journal.mjs +11 -2
  26. package/stack/notify.d.mts +9 -0
  27. package/stack/notify.mjs +32 -0
  28. package/stack/release-store.d.mts +10 -6
  29. package/stack/release-store.mjs +40 -37
  30. package/stack/remote.mjs +5 -4
  31. package/stack/rollback-guard.d.mts +10 -0
  32. package/stack/rollback-guard.mjs +41 -0
  33. package/stack/ssh-gate.d.mts +35 -0
  34. package/stack/ssh-gate.mjs +145 -0
  35. package/stack/status.d.mts +3 -1
  36. package/stack/status.mjs +2 -1
  37. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +25 -6
  38. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +13 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.15.0 - 2026-10-31
4
+
5
+ - Added: R345 `FE_PANE_GRID_CHILD_NOT_PANE`, R346 `FE_PANE_CHILD_NOT_SECTION`, R347 `FE_PANE_SECTION_PADDING` and R348 `FE_PANE_GRID_FRAME` (eslint-grammar-core `pane-grid-children`, `pane-children`, `no-pane-section-padding`, `pane-grid-page`) and R349 `FE_HAND_BUILT_PANES` (eslint-canon-fe `no-hand-built-panes`) in the bundled rule catalog.
6
+ - Changed: the bundled canon pins name the cohort hfs 4.15.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.7.0, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.17.0.
7
+
8
+ ## 4.14.0 - 2026-10-31
9
+
10
+ - Added: `ruleParams.be.sqlPassThrough` and hfs.json `sides.be.sqlPassThrough` (a list of `{file, reason}`, side-relative forward-slash paths, unique): the files whose `.query(` passes through SQL text that is not the app's own (R334). The shape validators (`manifest-shape`, `rule-params-shape`, `declaration-shape`) accept it on the be side only, the resolver merges the app's list into the canon's like `dbFunctions`, and the canon lists the seed runner `src/modules/platform/database/seed-runner.service.ts`.
11
+ - Changed: R334 `BE_PERSISTENCE_NO_RAW_SQL` is one style: lite TypeScript holds no SQL text outside the test world (entities, or a listed function through `callDbFunction`; the world keeps raw SQL for probes only), the failure code text follows, R68 names the call and LISTEN statements, and the `be.tests.world` slot allows `<name>.sql.ts`.
12
+ - Changed: the lite skeleton's `platform/database/database.client.ts` writes no literal `.query(` but the call template: `advisoryXactLock` is `callDbFunction` of `hashtextextended(key, 0)` then `pg_advisory_xact_lock(hash)`, and `notify` is `callDbFunction` of `pg_notify`; its spec follows.
13
+ - Changed: the bundled canon pins name the cohort hfs 4.14.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.6.7, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6.
14
+
15
+ ## 4.13.3 - 2026-10-31
16
+
17
+ - Changed: the canon pins name @starci/ai-router 0.5.0 (many providers behind one virtual key, the persisted key blacklist) and the cohort that carries them: hfs 4.13.3, eslint-canon-be 3.10.1, eslint-canon-fe 9.6.6, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6; no rule changed.
18
+
19
+ ## 4.13.2 - 2026-10-31
20
+
21
+ - Added: R344 `BE_AI_VIRTUAL_KEY` in the bundled rule catalog and on the `be.integrations` slot: an app's ai-router gateway has one client key, `STARCI_AI_KEY`, read by `integrations/ai`, and no admin token; `@starci/eslint-canon-be` `ai-virtual-key-name` enforces it (ruling be-ai-one-virtual-key).
22
+ - Changed: the bundled canon pins name hfs 4.13.2 and the lint cohort eslint-canon-be 3.10.0, eslint-canon-fe 9.6.5, eslint-grammar-core and eslint-grammar-offset-pop 0.16.5.
23
+
24
+ ## 4.13.1 - 2026-10-31
25
+
26
+ The open items of the delivery pipeline (contexts/project/cicd.md, "As built").
27
+
28
+ - Added: `starci-ssh-gate`, the forced command of the CI SSH key. The release store's remote operations are named scripts with arguments of a closed alphabet (`stack/ssh-gate.mts`); the gate admits `docker system dial-stdio` and `starci-op <name> <args>` and refuses anything else with exit 126, saying nothing about the request. `starci app stack ci-key --env <env>` prints the owner-run install steps, `--print-gate` the gate text and `--public-key <file> --print-authorized-keys` the line (`restrict,command="starci-ssh-gate",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc <key>`). A sealed `vps-ci` member that holds `VPS_GATE: "1"` makes `stack connect` export `STARCI_SSH_GATE=1`, and the deploy, rollback, accept and status verbs then send the operations through the gate; without it they run the same bodies under `sh -c`. Not admitted yet: the post-deploy tasks and the manager backup and test decrypt of a stack database.
29
+ - Added: `deploy.notify` (`webhook: {member, key}`, optional `on`), off unless declared: one JSON webhook message (outcome, environment, stack, commit, tag, release, attempt, CI run URL, failure code) when an attempt ends; the URL is a key of a sealed member, https only, never printed; a failing webhook is a line of the result and never fails the deploy.
30
+ - Added: the journal document of an attempt carries `contract`, the contract migrations it applied, and the release store reads the journal (`journals()`). `stack rollback` refuses `STACK_ROLLBACK_CONTRACT` (dry run too) for a release older than the previous release of an attempt that applied a contract migration, or that the journal never saw once any is recorded, unless `--data-acknowledged`.
31
+ - Changed: the bundled canon pins name hfs 4.13.1 and the lint cohort eslint-canon-be 3.9.4, eslint-canon-fe 9.6.4, eslint-grammar-core and eslint-grammar-offset-pop 0.16.4 (their copies of the pins moved with them).
32
+ - Changed: the release store's lock, pointer, list, read, store, accept and journal commands are the named operations above; a releases base may not contain `..` anywhere.
33
+
3
34
  ## 4.13.0 - 2026-10-31
4
35
 
5
36
  The delivery pipeline of an app (contexts/project/cicd.md, ruling app-cicd-auto-deploy; owner approval 2026-10-31: every green release tag deploys by itself, STARCI_DEPLOY_PAUSED is the brake, agents deploy, the CI deploy key is sealed, pull requests get CI, contract migrations run after the accept, Sonar judges every full run, images are cached in GitHub Actions and built for amd64 unless declared).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.13.0",
3
+ "version": "4.15.0",
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",
@@ -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.16.3
34
+ version: 0.17.0
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.9.3
41
+ version: 3.11.0
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.6.3
48
+ version: 9.7.0
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.16.3
55
+ version: 0.17.0
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.16.3
62
+ version: 0.17.0
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -99,14 +99,14 @@ pins:
99
99
  source: packages/test-world/package.json
100
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
- version: 0.4.1
102
+ version: 0.5.0
103
103
  group: starci
104
104
  install: registry
105
105
  side: be
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.13.0
109
+ version: 4.15.0
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -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. 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."
1410
+ law: "SQL text carries no runtime substitution; values are numbered parameters. The one exception is R334's call and LISTEN statements in platform/database/database.client.ts, which splice 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"]
@@ -5828,7 +5828,7 @@ rules:
5828
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."
5829
5829
  - id: R334
5830
5830
  code: "BE_PERSISTENCE_NO_RAW_SQL"
5831
- law: "Lite domain, feature and test code reads and writes through TypeORM entities on an EntityManager (domain code the injected one, integration and e2e specs, builders and fixtures the world's `world.db.<connection>`) (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 the two raw SQL owners, platform/database and the test world src/tests/world, which wraps only what entities cannot express (a trigger, a database function, a row-level-security probe, a pgmq queue) in a named, typed world helper specs call; a database function TypeORM cannot express is called, in domain and test code alike, 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."
5831
+ law: "Lite TypeScript holds no SQL text, outside the test world. A table is read and written through its TypeORM entity on an EntityManager (domain code the injected one; specs, builders, fixtures and the test world the world's `world.db.<connection>`): find, save, update, delete, upsert, a pessimistic `lock` option, `createQueryBuilder(Entity, \"alias\")` with named parameters; never a table-name builder or a Repository. Every table a migration creates has an entity. Anything else is a database function, called through `callDbFunction` of `platform/database/database.client.ts`, which names only a function that `ruleParams.be.dbFunctions` or hfs.json `sides.be.dbFunctions` lists with a reason: a function of a migration, schema-qualified (`public.job_claim`); an extension function, schema-qualified; a pg built-in of the canon's list, bare (`pg_notify`, called in pg_catalog). `database.client.ts` alone composes SQL text (the call statement and LISTEN), from parts its validator checked; `advisoryXactLock` and `notify` are `callDbFunction` calls of built-ins there. The only other `.query(` outside the world is the pass-through of a file that `ruleParams.be.sqlPassThrough` or hfs.json `sides.be.sqlPassThrough` lists with a reason, whose text is not the app's own (the tracked seed, a customer data-source read, an outside session's setup), with a plain string first argument, never a template or a `+`. The test world `src/tests/world` may hold raw SQL, as `sql`-tagged text with numbered `$n` parameters (and `<name>.sql.ts` files), only for what neither an entity nor a listed function expresses: a privilege or row-level-security probe, catalog, cron or trigger introspection, a pgmq queue table, a lock or failure probe; in the world too plain CRUD on an application table goes through its entity, an application function through `callDbFunction`, and seeded rows come from builders (R98). No `sql` template, `*.sql.ts`, `sql`/`ident` import or other `.query(` exists anywhere else."
5832
5832
  kinds: [lint]
5833
5833
  gates: [pre-commit, settle, land, ci]
5834
5834
  failureCodes: ["BE_PERSISTENCE_NO_RAW_SQL"]
@@ -6046,3 +6046,141 @@ rules:
6046
6046
  source: owner
6047
6047
  quote: "app check cache lại vào file dc không? sau chỉ check diff; chốt starcistacks lưu hết nhé và check diff và triển cho ci và local lun."
6048
6048
  ruling: "The app commits its check results in .starcistacks/cache/; CI trusts only the cache at the trusted base, a forged record blocks at any count, stale and missing only warn (ruling app-check-cache)."
6049
+ - id: R344
6050
+ code: "BE_AI_VIRTUAL_KEY"
6051
+ law: "An app's ai-router gateway has one client key, STARCI_AI_KEY (sk-starci-<app>-<48 hex>, sealed in the ai member, used by the integrations/ai LangChain client and the agent runtime alike), and no admin token: integrations/ai reads exactly that secret name with env.secret, as a literal, and the custody owner integrations/ai-gateway passes the gateway no adminToken."
6052
+ kinds: [lint]
6053
+ gates: [pre-commit, settle, land, ci]
6054
+ failureCodes: ["BE_AI_VIRTUAL_KEY"]
6055
+ enforcers:
6056
+ - {kind: eslint-be, id: ai-virtual-key-name}
6057
+ editions: [lite, full]
6058
+ docs: [contexts/code/be/ai.md]
6059
+ templateNa: "The skeleton scaffolds no model call; an app adds the ai and ai-gateway integrations and the ai member when it calls a model."
6060
+ proves:
6061
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_VIRTUAL_KEY]}
6062
+ exemplifiedBy:
6063
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_VIRTUAL_KEY]}
6064
+ witness:
6065
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_VIRTUAL_KEY]}
6066
+ cases:
6067
+ - id: case-1
6068
+ date: "2026-10-31"
6069
+ source: owner
6070
+ quote: "Xóa ai api key và openclaw ai key chỉnh thành starci ai key là key ảo"
6071
+ ruling: "Per app and environment the ai member holds the upstream provider keys and one random virtual key STARCI_AI_KEY that the gateway mounts and every client uses; the gateway has no admin API (ruling be-ai-one-virtual-key)."
6072
+ - id: R345
6073
+ code: "FE_PANE_GRID_CHILD_NOT_PANE"
6074
+ law: "A child of a Grammar PaneGrid is a literal Pane element, written directly or returned by a conditional (`cond ? <Pane/> : null`, `cond && <Pane/>`) or a `.map`; a wrapper component, a raw element, a fragment or a variable is refused, because PaneGrid places each child by the area of a Pane and drops the rest, leaving its cell blank. A Freestyle subtree leaves the raw elements inside it alone."
6075
+ kinds: [lint]
6076
+ gates: [pre-commit, settle, land, ci]
6077
+ failureCodes: ["FE_PANE_GRID_CHILD_NOT_PANE"]
6078
+ enforcers:
6079
+ - {kind: eslint-grammar-core, id: pane-grid-children}
6080
+ editions: [lite, full]
6081
+ docs: [contexts/ui/components/pane-grid.md]
6082
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6083
+ proves:
6084
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
6085
+ exemplifiedBy:
6086
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
6087
+ witness:
6088
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
6089
+ cases:
6090
+ - id: case-1
6091
+ date: "2026-10-31"
6092
+ source: owner
6093
+ quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
6094
+ ruling: "A Pane owns its content inset and every band in it is a PaneSection; a PaneGrid holds Panes only, so the lint refuses a wrapper component that the grid would drop."
6095
+ - id: R346
6096
+ code: "FE_PANE_CHILD_NOT_SECTION"
6097
+ law: "A Grammar Pane holds PaneSection elements or one nested PaneGrid; a raw element, or a Grammar component other than PaneSection, PaneGrid and Freestyle, placed directly in a Pane skips the inset the pane publishes to its bands. A project component is judged in its own file, and a Freestyle subtree leaves its raw elements alone."
6098
+ kinds: [lint]
6099
+ gates: [pre-commit, settle, land, ci]
6100
+ failureCodes: ["FE_PANE_CHILD_NOT_SECTION"]
6101
+ enforcers:
6102
+ - {kind: eslint-grammar-core, id: pane-children}
6103
+ editions: [lite, full]
6104
+ docs: [contexts/ui/components/pane-grid.md]
6105
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6106
+ proves:
6107
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
6108
+ exemplifiedBy:
6109
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
6110
+ witness:
6111
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
6112
+ cases:
6113
+ - id: case-1
6114
+ date: "2026-10-31"
6115
+ source: owner
6116
+ quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
6117
+ ruling: "A Pane owns its content inset and every band in it is a PaneSection whose boxes align to that edge, so the lint refuses a band hand-built in a pane."
6118
+ - id: R347
6119
+ code: "FE_PANE_SECTION_PADDING"
6120
+ law: "The outermost element a Grammar PaneSection renders, and the root of a same-file component returned as its child, carries no padding or margin utility (`p-*`, `px-*`, `py-*`, `ps/pe/pt/pb/pl/pr-*`, `m-*`, `mx/my/ms/me/mt/mb/ml/mr-*`, their negative forms, or an arbitrary padding or margin property; zero and auto values place or reset and are not one), because the Pane owns the content inset and the section applies it. A Freestyle subtree leaves its raw elements alone."
6121
+ kinds: [lint]
6122
+ gates: [pre-commit, settle, land, ci]
6123
+ failureCodes: ["FE_PANE_SECTION_PADDING"]
6124
+ enforcers:
6125
+ - {kind: eslint-grammar-core, id: no-pane-section-padding}
6126
+ editions: [lite, full]
6127
+ docs: [contexts/ui/components/pane-grid.md]
6128
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6129
+ proves:
6130
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
6131
+ exemplifiedBy:
6132
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
6133
+ witness:
6134
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
6135
+ cases:
6136
+ - id: case-1
6137
+ date: "2026-10-31"
6138
+ source: owner
6139
+ quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
6140
+ ruling: "One padding system for everything placed in a PaneGrid: the Pane publishes the inset and the PaneSection applies it, so no band adds its own."
6141
+ - id: R348
6142
+ code: "FE_PANE_GRID_FRAME"
6143
+ law: "A Grammar PaneGrid is the one child of `Page chrome=\"panes\"` or stands inside a Pane; a PaneGrid drawn in a FillViewport, a SurfaceCard, SurfaceListCard or SurfaceAccordionCard, a Page of another chrome, or wrapped inside `Page chrome=\"panes\"` re-decides the frame and the bottom edge that Page owns. A grid whose frame lives in another file is judged where the frame is, and a Freestyle subtree is not judged."
6144
+ kinds: [lint]
6145
+ gates: [pre-commit, settle, land, ci]
6146
+ failureCodes: ["FE_PANE_GRID_FRAME"]
6147
+ enforcers:
6148
+ - {kind: eslint-grammar-core, id: pane-grid-page}
6149
+ editions: [lite, full]
6150
+ docs: [contexts/ui/components/pane-grid.md]
6151
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6152
+ proves:
6153
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
6154
+ exemplifiedBy:
6155
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
6156
+ witness:
6157
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
6158
+ cases:
6159
+ - id: case-1
6160
+ date: "2026-10-31"
6161
+ source: owner
6162
+ quote: "sao trang lan agent có gap 1 khoảng trên vậy??? … phải dính vào chứ"
6163
+ ruling: "A tabbed app page's PaneGrid attaches flush under the tab line, full width and to the bottom, with no card, radius or gap: Page chrome=\"panes\"."
6164
+ - id: R349
6165
+ code: "FE_HAND_BUILT_PANES"
6166
+ law: "A multi-pane frame is a Grammar PaneGrid of Panes, which draws the dividers between panes; a vertical Divider used as a column seam (among the plain columns of a flex row, or anywhere inside a SurfaceCard composition=\"joined\") and a joined SurfaceCard that holds a flex row of two or more plain columns are a pane frame drawn by hand and are refused. A short vertical separator between controls and a row whose classes cannot be read in the file are not judged."
6167
+ kinds: [lint]
6168
+ gates: [pre-commit, settle, land, ci]
6169
+ failureCodes: ["FE_HAND_BUILT_PANES"]
6170
+ enforcers:
6171
+ - {kind: eslint-fe, id: no-hand-built-panes}
6172
+ editions: [lite, full]
6173
+ docs: [contexts/ui/components/pane-grid.md]
6174
+ templateNa: "A template draws no multi-pane frame; an app adds one with its multi-pane screens."
6175
+ proves:
6176
+ - {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
6177
+ exemplifiedBy:
6178
+ - {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
6179
+ witness:
6180
+ - {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
6181
+ cases:
6182
+ - id: case-1
6183
+ date: "2026-10-31"
6184
+ source: owner
6185
+ quote: "sao trang lan agent có gap 1 khoảng trên vậy??? … phải dính vào chứ"
6186
+ ruling: "A multi-pane frame is the Grammar's PaneGrid, never columns and a vertical Divider composed by hand inside a card."
@@ -467,6 +467,10 @@ ruleParams:
467
467
  - {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"}
468
468
  - {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
469
469
  - {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
470
+ # R334 (lite): the only `.query(` outside platform/database's composer and the test world is the pass-through of a file listed here
471
+ # (or in hfs.json sides.be.sqlPassThrough), whose SQL text is not the app's own. Paths are relative to the side folder.
472
+ sqlPassThrough:
473
+ - {file: src/modules/platform/database/seed-runner.service.ts, reason: "the tracked seed file's text, run as it is (db/seed.sql); the app writes no SQL there"}
470
474
 
471
475
  lite:
472
476
  schemaAuthority: sql
@@ -1999,7 +2003,7 @@ slots:
1999
2003
  tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
2000
2004
  coverage: required
2001
2005
  budget: {indexExports: 80} # a provider's port surface includes clients, config, options, tokens and errors
2002
- rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT]
2006
+ rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT, BE_AI_VIRTUAL_KEY]
2003
2007
  - id: be.integrations.model
2004
2008
  since: 3.1.0
2005
2009
  profiles: [be]
@@ -2070,7 +2074,7 @@ slots:
2070
2074
  own; use-test-world.ts exports useTestWorld({ modules: [...] }) -> world.db.<connection> (the shared EntityManager) and
2071
2075
  world.fake.<provider> (a network-edge fake of an external SaaS). There is no world.apps (no e2e) and no world.infra
2072
2076
  (no self-hosted service to cut)
2073
- allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes/] # jest globalSetup/globalTeardown default-export (Jest API); fakes/<provider>/server.ts is the fake server of a provider, a literal file name BE_SOURCE_FORM reads as its role; plus root files with a role suffix (test-world-files, R47)
2077
+ allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes/, "<name>.sql.ts"] # jest globalSetup/globalTeardown default-export (Jest API); fakes/<provider>/server.ts is the fake server of a provider, a literal file name BE_SOURCE_FORM reads as its role; plus root files with a role suffix (test-world-files, R47)
2074
2078
  tests: none
2075
2079
  coverage: none
2076
2080
  why: >-
@@ -5634,12 +5634,12 @@ LITE_DB_MIGRATION:
5634
5634
  kind: check-finding
5635
5635
 
5636
5636
  BE_PERSISTENCE_NO_RAW_SQL:
5637
- title: "Lite domain, feature or test code runs raw SQL instead of TypeORM entities"
5638
- title_vi: "Mã domain, feature hoặc test của app lite chạy SQL thô thay vì entity TypeORM"
5639
- meaning_vi: "File `<path>` gọi `.query(` hoặc dùng template `sql` ngoài hai chủ của SQL thô là platform/database và test world src/tests/world, hoặc gọi callDbFunction với một hàm không có trong ruleParams.be.dbFunctions hay hfs.json sides.be.dbFunctions."
5637
+ title: "Lite TypeScript runs or holds SQL text outside the test world"
5638
+ title_vi: "TypeScript của app lite chạy hoặc chứa văn bản SQL ngoài test world"
5639
+ meaning_vi: "File `<path>` gọi `.query(`, dùng template `sql`, là file `*.sql.ts`, import `sql`/`ident`/`SqlText`/`SqlIdent` ngoài src/tests/world (trừ bộ soạn câu lệnh của platform/database/database.client.ts và file có tên trong ruleParams.be.sqlPassThrough hay hfs.json sides.be.sqlPassThrough), gọi callDbFunction với một hàm không có trong ruleParams.be.dbFunctions hay hfs.json sides.be.dbFunctions, hoặc trong test world viết SQL thô cho điều entity hoặc hàm đã khai báo diễn đạt được (gọi hàm, ghi bảng ứng dụng)."
5640
5640
  causes_vi:
5641
- - "Vi phạm R334: dữ liệu của app lite, kể cả spec, builder và fixture, đi qua entity TypeORM trên EntityManager (find, save, update, upsert, createQueryBuilder); spec dùng world.db.<connection>."
5642
- nextStep_vi: "Đọc/ghi bằng entity trên EntityManager hoặc createQueryBuilder với tham số có tên (trong test là world.db.<connection>); hàm database TypeORM không diễn đạt được thì gọi qua callDbFunction với hàm đã khai kèm lý do; điều entity không diễn đạt được trong test (trigger, thăm dò row-level security, hàng đợi pgmq) thì gọi một helper có tên của src/tests/world."
5641
+ - "Vi phạm R334: dữ liệu của app lite, kể cả spec, builder, fixture và test world, đi qua entity TypeORM trên EntityManager (find, save, update, upsert, createQueryBuilder); hàm database đi qua callDbFunction; SQL thô chỉ còn ở test world cho thăm dò (quyền, row-level security, catalog, cron, trigger, bảng pgmq, khóa/lỗi)."
5642
+ nextStep_vi: "Đọc/ghi bằng entity trên EntityManager hoặc createQueryBuilder với tham số có tên (trong test là world.db.<connection>); hàm database thì gọi qua callDbFunction với hàm đã khai kèm lý do; file chỉ chuyển tiếp văn bản SQL không phải của app thì khai trong hfs.json sides.be.sqlPassThrough kèm lý do; thăm dò chỉ test world mới được viết SQL thô."
5643
5643
  owner: op-retry
5644
5644
  kind: check-finding
5645
5645
 
@@ -633,6 +633,7 @@ $defs:
633
633
  uniqueItems: true
634
634
  items: {type: string, pattern: '^[a-z][a-z0-9-]{0,40}$'}
635
635
  backup: {$ref: '#/$defs/deployBackup'}
636
+ notify: {$ref: '#/$defs/deployNotify'}
636
637
  registry: {$ref: '#/$defs/deployRegistry'}
637
638
  ingress:
638
639
  description: The shared host ingress (stack-ingress-config.mjs is the authority for the fields; starci app check R294 judges them).
@@ -850,6 +851,27 @@ $defs:
850
851
  type: array
851
852
  minItems: 1
852
853
  items: {$ref: '#/$defs/deployBackupVolume'}
854
+ deployNotify:
855
+ description: >-
856
+ One webhook message per deploy outcome (accepted, rolled-back, rollback-failed, failed; `refused` only when listed): the outcome, the environment, the commit, the tag, the
857
+ release and the attempt, as JSON with a `text` and a `content` line so a chat webhook shows it. Off unless declared. The URL is a sealed value of the environment
858
+ (https only), never written here; a failing webhook is a line of the result and never fails the deploy.
859
+ type: object
860
+ additionalProperties: false
861
+ required: [webhook]
862
+ properties:
863
+ webhook:
864
+ type: object
865
+ additionalProperties: false
866
+ required: [member, key]
867
+ properties:
868
+ member: {$ref: '#/$defs/deployName'}
869
+ key: {$ref: '#/$defs/deployKey'}
870
+ on:
871
+ type: array
872
+ uniqueItems: true
873
+ minItems: 1
874
+ items: {enum: [accepted, rolled-back, rollback-failed, failed, refused]}
853
875
  deployRegistry:
854
876
  description: >-
855
877
  The image registry and the visibility the app's packages are meant to have. The canon scaffold writes `public`; the value then belongs to the app.
@@ -1,6 +1,6 @@
1
1
  import { isPlainObject } from '../../engine/plain-object.mjs';
2
2
  import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
3
- import { dbFunctionsOk } from './rule-params-shape.mjs';
3
+ import { dbFunctionsOk, sqlPassThroughOk } from './rule-params-shape.mjs';
4
4
  import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
5
5
  import { byCodeUnit } from '../lib/list.mjs';
6
6
  /** Shape problems of the optional hfs.json `db` block of a lite app: the environment variable naming its database, its image, forced RLS and its application role. */
@@ -91,10 +91,12 @@ function declarationSideProblems(side, value, names) {
91
91
  if (!isPlainObject(value))
92
92
  return [`${at} must be an object`];
93
93
  for (const key of Object.keys(value))
94
- if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions'].includes(key))
94
+ if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions', 'sqlPassThrough'].includes(key))
95
95
  bad.push(`${at} has unknown key ${key}`);
96
96
  if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions, { bare: true })))
97
97
  bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions, each schema-qualified (schema.name, never pg_catalog) or a bare pg built-in of the canon's list, with unique names`);
98
+ if (value.sqlPassThrough !== undefined && (side !== 'be' || !sqlPassThroughOk(value.sqlPassThrough)))
99
+ bad.push(`${at}.sqlPassThrough belongs to the be side and lists {file, reason} files relative to the side folder, forward slashes, unique, each with the reason its SQL text is not the app's own`);
98
100
  bad.push(...appShapeProblems(value.apps, at, side, names));
99
101
  if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot))))
100
102
  bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
@@ -92,6 +92,11 @@ export interface DbFunction {
92
92
  name: string;
93
93
  reason: string;
94
94
  }
95
+ /** A file whose `.query(` passes through SQL text that is not the app's own (ruleParams.be.sqlPassThrough, hfs.json sides.be.sqlPassThrough). */
96
+ export interface SqlPassThrough {
97
+ file: string;
98
+ reason: string;
99
+ }
95
100
  /** The rule parameters of one profile (ruleParams.common merged with ruleParams.<profile>); the keys not named here are parsed manifest data. */
96
101
  export interface RuleParams {
97
102
  [key: string]: Parsed;
@@ -107,6 +112,7 @@ export interface RuleParams {
107
112
  suffixes?: string[];
108
113
  bannedSuffixes?: string[];
109
114
  dbFunctions?: DbFunction[];
115
+ sqlPassThrough?: SqlPassThrough[];
110
116
  }
111
117
  /** ruleParams.be: the back end's own parameters, which the validators of this folder require. */
112
118
  export interface BeRuleParams extends RuleParams {
@@ -6,5 +6,10 @@ import type { Parsed } from './manifest-shape.mjs';
6
6
  export declare const dbFunctionsOk: (list: Parsed, { bare }?: {
7
7
  bare?: boolean;
8
8
  }) => boolean;
9
+ /**
10
+ * True for an absent list or a list of {file, reason} SQL pass-through files (R334): `file` is a forward-slash path relative to the
11
+ * side folder (no leading slash, no `..`), unique, and the reason names whose text the file passes through.
12
+ */
13
+ export declare const sqlPassThroughOk: (list: Parsed) => boolean;
9
14
  /** The shape problems of `m.ruleParams`. */
10
15
  export declare function ruleParamsProblems(m: Parsed): string[];
@@ -73,6 +73,11 @@ const dbFunctionNameOk = (name, bare) => typeof name === 'string' && (/^[a-z_][a
73
73
  * a bare name, which only the canon's list (ruleParams.be.dbFunctions) uses, for the pg built-ins its helpers call.
74
74
  */
75
75
  export const dbFunctionsOk = (list, { bare = false } = {}) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'name,reason' && dbFunctionNameOk(entry.name, bare) && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.name)).size === list.length);
76
+ /**
77
+ * True for an absent list or a list of {file, reason} SQL pass-through files (R334): `file` is a forward-slash path relative to the
78
+ * side folder (no leading slash, no `..`), unique, and the reason names whose text the file passes through.
79
+ */
80
+ export const sqlPassThroughOk = (list) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'file,reason' && typeof entry.file === 'string' && /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(entry.file) && !entry.file.split('/').includes('..') && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.file)).size === list.length);
76
81
  /** The shape problems of `m.ruleParams`. */
77
82
  export function ruleParamsProblems(m) {
78
83
  const bad = [];
@@ -84,8 +89,8 @@ export function ruleParamsProblems(m) {
84
89
  // Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
85
90
  if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock))
86
91
  bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
87
- if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions']))
88
- bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name) and lite (overrides of the same keys)');
92
+ if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions', 'sqlPassThrough']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !sqlPassThroughOk(rp.be.sqlPassThrough) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions', 'sqlPassThrough']))
93
+ bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name), sqlPassThrough ([{file, reason}]: a file whose SQL text is not written by the app) and lite (overrides of the same keys)');
89
94
  else
90
95
  bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
91
96
  bad.push(...beSpecificProblems(rp.be, m));
@@ -3,7 +3,7 @@ import type { Side } from './slot-manifest-shape.mjs';
3
3
  import type { AppRepo } from './slot-app-view.mjs';
4
4
  import { loadRuleCatalog } from './rule-catalog.mjs';
5
5
  import type { RuleCatalog } from './rule-catalog.mjs';
6
- import type { DbFunction, Parsed, RuleParams, Slot, SlotManifest, SlotTracking } from './manifest-shape.mjs';
6
+ import type { DbFunction, Parsed, RuleParams, Slot, SlotManifest, SlotTracking, SqlPassThrough } from './manifest-shape.mjs';
7
7
  import type { ClassifiedPath, OwnerUnit } from './slot-classify.mjs';
8
8
  export type { Bindings } from './slot-match.mjs';
9
9
  export type { ClassifiedPath, OwnerUnit, PlacedPath, AmbiguousPath, UndeclaredPath } from './slot-classify.mjs';
@@ -41,6 +41,7 @@ export interface RepoView {
41
41
  providers?: readonly string[];
42
42
  reads: readonly string[];
43
43
  dbFunctions?: readonly DbFunction[];
44
+ sqlPassThrough?: readonly SqlPassThrough[];
44
45
  grammar?: string;
45
46
  extraPackages?: readonly {
46
47
  name: string;
@@ -125,6 +125,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
125
125
  providers,
126
126
  reads: Object.freeze([...(s.reads ?? [])]),
127
127
  dbFunctions: Object.freeze((s.dbFunctions ?? []).map((entry) => Object.freeze({ ...entry }))),
128
+ sqlPassThrough: Object.freeze((s.sqlPassThrough ?? []).map((entry) => Object.freeze({ ...entry }))),
128
129
  ...(s.grammar === undefined ? {} : { grammar: s.grammar }),
129
130
  ...(s.extraPackages === undefined ? {} : { extraPackages: Object.freeze(s.extraPackages.map((entry) => Object.freeze({ ...entry }))) }),
130
131
  manifestVersion: manifest.version,
@@ -251,8 +252,8 @@ function createScopeResolver(manifest, repo) {
251
252
  trackingOf,
252
253
  /** True only for a path a slot owns and requires to be committed. */
253
254
  isTracked: (p) => trackingOf(p) === 'tracked',
254
- // The app's own database functions (hfs.json sides.be.dbFunctions) join the canon's list (R334).
255
- ruleParams: () => withDeclaredDbFunctions(ruleParams(manifest, profile, edition), repo.dbFunctions),
255
+ // The app's own database functions and SQL pass-through files (hfs.json sides.be.dbFunctions, sides.be.sqlPassThrough) join the canon's lists (R334).
256
+ ruleParams: () => withDeclaredLists(ruleParams(manifest, profile, edition), repo),
256
257
  allowedImports: (tier) => manifest.tiers[profile]?.[tier]?.mayImport ?? null,
257
258
  });
258
259
  }
@@ -274,9 +275,18 @@ export function createSlotResolver(manifest, repo) {
274
275
  const sides = Object.fromEntries(PROFILES.map((side) => [side, createScopeResolver(manifest, app.sides[side])]));
275
276
  return appResolver(app, root, sides);
276
277
  }
277
- /** The rule parameters with the app's declared database functions appended to `dbFunctions` (be only), frozen. */
278
- const withDeclaredDbFunctions = (params, declared = []) => (params.dbFunctions === undefined && !declared.length ? params
279
- : Object.freeze({ ...params, dbFunctions: Object.freeze([...(params.dbFunctions ?? []), ...declared]) }));
278
+ /** The rule parameters with the app's declared database functions and SQL pass-through files appended to `dbFunctions` and `sqlPassThrough` (be only), the result frozen. */
279
+ const withDeclaredLists = (params, repo) => {
280
+ const functions = repo.dbFunctions ?? [];
281
+ const passThrough = repo.sqlPassThrough ?? [];
282
+ if ((params.dbFunctions === undefined && !functions.length) && (params.sqlPassThrough === undefined && !passThrough.length))
283
+ return params;
284
+ return Object.freeze({
285
+ ...params,
286
+ ...(params.dbFunctions === undefined && !functions.length ? {} : { dbFunctions: [...(params.dbFunctions ?? []), ...functions] }),
287
+ ...(params.sqlPassThrough === undefined && !passThrough.length ? {} : { sqlPassThrough: [...(params.sqlPassThrough ?? []), ...passThrough] }),
288
+ });
289
+ };
280
290
  /** The rule parameters of one profile (be: infraOwners, suffixes, bannedSuffixes and the rest over the shared ruleParams.common fileLines and duplicateBlock; fe: common alone or with its overrides; runtime: ruleParams.runtime of a runtime manifest), as a frozen deep copy. Under `edition` lite the `lite` overrides of ruleParams.<profile> merge over the base (the `lite` key itself is never returned). */
281
291
  export function ruleParams(manifest, profile, edition = 'full') {
282
292
  const profiles = manifestKind(manifest) === RUNTIME_KIND ? [RUNTIME_KIND] : PROFILES;
@@ -99,6 +99,16 @@ export interface BackupConfig {
99
99
  volume: string;
100
100
  }>;
101
101
  }
102
+ /** The outcomes of a deploy attempt a notification can be sent for. */
103
+ export declare const NOTIFY_OUTCOMES: readonly ["accepted", "rolled-back", "rollback-failed", "failed", "refused"];
104
+ /** `deploy.notify`: the sealed member and key that hold the webhook URL (https), and the outcomes that are announced. */
105
+ export interface NotifyConfig {
106
+ webhook: {
107
+ member: string;
108
+ key: string;
109
+ };
110
+ on: string[];
111
+ }
102
112
  export interface RegistryConfig {
103
113
  host: string;
104
114
  visibility: string;
@@ -124,6 +134,8 @@ export interface DeployConfig {
124
134
  tasks: Record<string, TaskConfig>;
125
135
  postDeploy: string[];
126
136
  backup: BackupConfig | null;
137
+ /** deploy.notify: one webhook message per deploy outcome; null (off) unless declared. */
138
+ notify: NotifyConfig | null;
127
139
  ingress: IngressConfig | null;
128
140
  registry: RegistryConfig;
129
141
  }
@@ -5,6 +5,8 @@ import { isPlainObject as plain } from '../../engine/plain-object.mjs';
5
5
  import { closedFields, collector, patternField, treeFile } from './stack-config-kit.mjs';
6
6
  import { list } from '../lib/list.mjs';
7
7
  import { normalizeIngress } from './stack-ingress-config.mjs';
8
+ /** The outcomes of a deploy attempt a notification can be sent for. */
9
+ export const NOTIFY_OUTCOMES = ['accepted', 'rolled-back', 'rollback-failed', 'failed', 'refused'];
8
10
  export const DEPLOY_CONFIG_CODE = 'STACKS_DEPLOY_CONFIG_INVALID';
9
11
  export const NAME = /^[a-z][a-z0-9-]*$/u;
10
12
  export const KEY = /^[A-Z][A-Z0-9_]*$/u;
@@ -17,7 +19,7 @@ const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}
17
19
  const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
18
20
  const UPPER_ENV = KEY;
19
21
  const FIELDS = {
20
- deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
22
+ deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'notify', 'ingress', 'registry'],
21
23
  remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
22
24
  image: ['app', 'buildArgs'],
23
25
  buildArgs: ['values', 'sealed'],
@@ -36,6 +38,8 @@ const FIELDS = {
36
38
  backup: ['image', 'keepDays', 'dir', 'volumes'],
37
39
  backupVolume: ['name', 'volume'],
38
40
  registry: ['host', 'visibility'],
41
+ notify: ['webhook', 'on'],
42
+ webhook: ['member', 'key'],
39
43
  };
40
44
  const text = (value) => typeof value === 'string' && value.length > 0;
41
45
  const APPROVALS = ['none', 'reviewers'];
@@ -409,6 +413,22 @@ function backup(c, block) {
409
413
  c.problem('deploy.backup.volumes', 'must list at least one volume');
410
414
  return { image: block.image, keepDays: block.keepDays ?? 14, dir: block.dir ?? null, volumes };
411
415
  }
416
+ /**
417
+ * `deploy.notify`: off unless declared. The webhook URL is a sealed value (never in the declaration, the log or the journal); the default announces every
418
+ * outcome that changed or tried to change the services (accepted, rolled-back, rollback-failed, failed), and `on` narrows or adds `refused`.
419
+ */
420
+ function notify(c, block, members) {
421
+ if (block === undefined)
422
+ return null;
423
+ if (!closedFields(c, 'deploy.notify', block, FIELDS.notify))
424
+ return null;
425
+ if (!closedFields(c, 'deploy.notify.webhook', block.webhook, FIELDS.webhook))
426
+ return null;
427
+ memberRef(c, 'deploy.notify.webhook.member', block.webhook.member, members);
428
+ patternField(c, 'deploy.notify.webhook.key', block.webhook.key, KEY);
429
+ const on = block.on === undefined ? ['accepted', 'rolled-back', 'rollback-failed', 'failed'] : stringList(c, 'deploy.notify.on', block.on, new RegExp(`^(?:${NOTIFY_OUTCOMES.join('|')})$`, 'u'), { nonEmpty: true });
430
+ return { webhook: { member: block.webhook.member, key: block.webhook.key }, on };
431
+ }
412
432
  function ingress(c, block, probe) {
413
433
  if (block === undefined)
414
434
  return null;
@@ -450,6 +470,7 @@ export function normalizeDeploy(block, { project, probe }) {
450
470
  tasks,
451
471
  postDeploy: postDeploy(c, block.postDeploy, tasks),
452
472
  backup: backup(c, block.backup),
473
+ notify: notify(c, block.notify, members),
453
474
  ingress: ingress(c, block.ingress, probe),
454
475
  registry: registry(c, block.registry),
455
476
  };