@starci/hfs 4.9.9 → 4.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +14 -0
  3. package/db/drift.mjs +289 -0
  4. package/db/dump.mjs +1 -1
  5. package/db/entity-list.mjs +9 -0
  6. package/db/migrate.mjs +1 -1
  7. package/lint/run.mjs +12 -2
  8. package/package.json +5 -3
  9. package/release/gate.mjs +81 -18
  10. package/release/index.mjs +19 -8
  11. package/release/push.mjs +4 -4
  12. package/release/records.mjs +54 -0
  13. package/release/urgent.mjs +129 -0
  14. package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
  15. package/runtime/knowledge/hfs/rules.yaml +74 -3
  16. package/runtime/knowledge/hfs/slots.yaml +36 -3
  17. package/runtime/modules/kernel/failure-codes.yaml +21 -0
  18. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  19. package/runtime/scripts/hfs/check.mjs +2 -2
  20. package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
  21. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  22. package/src/main.mjs +7 -4
  23. package/stack/cli.mjs +10 -2
  24. package/stack/deploy.mjs +21 -4
  25. package/stack/urgent-floor.mjs +147 -0
  26. package/sync/ci-commands.mjs +3 -1
  27. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  28. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  29. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  30. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  31. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  32. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  33. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  34. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  35. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  36. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  37. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  38. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  39. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
  40. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
@@ -1820,7 +1820,7 @@ rules:
1820
1820
  - {path: packages/eslint/be/be-r89-source-form-coverage.spec.mjs, edition: full, codes: [BE_SOURCE_FORM, BE_PUBLIC_CONTRACT_FORM]}
1821
1821
  - id: R90
1822
1822
  code: "BE_INFRA_OWNER"
1823
- law: "Each raw infrastructure library (HTTP, cache, queue, scheduler, logger, date, config, events) is imported or referenced only by its one owning platform or integration capability, and the `HttpClient` port of `platform/http` is called only by an `integrations` capability."
1823
+ law: "Each raw infrastructure library (HTTP, cache, queue, scheduler, logger, date, config, events, model SDKs) is imported or referenced only by its one owning platform or integration capability, and the `HttpClient` port of `platform/http` is called only by an `integrations` capability."
1824
1824
  kinds: [lint, codemod]
1825
1825
  failureCodes: ["BE_INFRA_OWNER"]
1826
1826
  enforcers:
@@ -4716,10 +4716,10 @@ rules:
4716
4716
  - {path: packages/hfs/src/hfs-release-provenance.spec.mjs, edition: full}
4717
4717
  - id: R272
4718
4718
  code: "BE_APP_BOOT_PROOF_MISSING"
4719
- law: "Every lite back-end app proves its real Nest AppModule composition by compiling the dependency graph with explicit fixture options and infrastructure doubles; unresolved providers, circular dependencies and a missing or failed proof are refused. The app check wiring verb uses the same proof. Full retains its existing e2e boot proof."
4719
+ law: "Every lite back-end app proves its real Nest AppModule composition by compiling the dependency graph with explicit fixture options and infrastructure doubles; unresolved providers, circular dependencies and a missing or failed proof are refused. The proof worker runs within its own budget from its spawn and is retried once when it runs out; a second timeout reaches no verdict and is reported as BOOT_PROOF_TIMEOUT, a warning, never as a failed proof. The app check wiring verb uses the same proof. Full retains its existing e2e boot proof."
4720
4720
  kinds: [check]
4721
4721
  gates: ["settle","land","ci"]
4722
- failureCodes: ["BE_APP_BOOT_PROOF_MISSING"]
4722
+ failureCodes: ["BE_APP_BOOT_PROOF_MISSING", "BOOT_PROOF_TIMEOUT"]
4723
4723
  enforcers:
4724
4724
  - {kind: hfs, id: boot-proof, at: scripts/hfs/rules/boot-proof.mjs}
4725
4725
  editions: ["lite"]
@@ -5756,3 +5756,74 @@ rules:
5756
5756
  source: owner
5757
5757
  quote: "ok trò"
5758
5758
  ruling: "A route page is one Grammar Page (owner ruling page-frame): the single owner of the page inset and rhythm; the lint refuses a sibling beside the Page and a bare PageHeader or PageContainer in a page owner."
5759
+ - id: R326
5760
+ code: "BE_AI_KEY_CUSTODY"
5761
+ law: "An upstream model provider key variable (OPENROUTER_API_KEY, OPENAI_API_KEY and the rest of the canon's one list) is named only inside the ai-router gateway's custody owner integrations/ai-gateway, in a literal, a member or a destructured property; every other file reaches a model through the gateway with a virtual key."
5762
+ kinds: [lint]
5763
+ gates: [pre-commit, settle, land, ci]
5764
+ failureCodes: ["BE_AI_KEY_CUSTODY"]
5765
+ enforcers:
5766
+ - {kind: eslint-be, id: provider-key-custody}
5767
+ editions: [lite, full]
5768
+ docs: [contexts/code/be/ai.md]
5769
+ templateNa: "The skeleton scaffolds no model call; an app adds the ai and ai-gateway integrations when it calls a model."
5770
+ proves:
5771
+ - packages/eslint-canon-be/src/ai-gateway.spec.ts
5772
+ exemplifiedBy:
5773
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_KEY_CUSTODY]}
5774
+ witness:
5775
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_KEY_CUSTODY]}
5776
+ cases:
5777
+ - id: case-1
5778
+ date: "2026-10-29"
5779
+ source: owner
5780
+ quote: "nhớ làm langchain nhé update .claude lun là call thẳng từ code phải dùng langchain với embeded context"
5781
+ ruling: "A model call made straight from code uses LangChain with retrieved context, through the ai-router gateway (owner ruling be-ai-langchain-gateway): only the gateway's custody owner reads a provider key."
5782
+ - id: R327
5783
+ code: "BE_AI_GATEWAY_CLIENT"
5784
+ law: "Code builds only the ChatOpenAI and OpenAIEmbeddings clients of @langchain/openai, each from an inline options object with no spread, its configuration.baseURL read from a <c>.options.ts type and its apiKey the reveal() of a platform/config Secret, so every model call goes through the ai-router gateway with a virtual key."
5785
+ kinds: [lint]
5786
+ gates: [pre-commit, settle, land, ci]
5787
+ failureCodes: ["BE_AI_GATEWAY_CLIENT"]
5788
+ enforcers:
5789
+ - {kind: eslint-be, id: ai-client-through-gateway}
5790
+ editions: [lite, full]
5791
+ docs: [contexts/code/be/ai.md]
5792
+ templateNa: "The skeleton scaffolds no model call; an app adds the ai and ai-gateway integrations when it calls a model."
5793
+ proves:
5794
+ - packages/eslint-canon-be/src/ai-gateway.spec.ts
5795
+ exemplifiedBy:
5796
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_GATEWAY_CLIENT]}
5797
+ witness:
5798
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_GATEWAY_CLIENT]}
5799
+ cases:
5800
+ - id: case-1
5801
+ date: "2026-10-29"
5802
+ source: owner
5803
+ quote: "nhớ làm langchain nhé update .claude lun là call thẳng từ code phải dùng langchain với embeded context"
5804
+ ruling: "A model call made straight from code uses LangChain with retrieved context, through the ai-router gateway (owner ruling be-ai-langchain-gateway): the LangChain clients point at the gateway's base URL with its virtual key."
5805
+ - id: R333
5806
+ code: "BE_ENTITY_SCHEMA_DRIFT"
5807
+ law: "Every TypeORM entity column of an app's entity list (be/src/platform/database/entities.ts) matches the schema its SQL migrations build: `starci app db drift` applies db/bootstrap.sql and every active db/migrations/*.sql to a scratch database (a container of the app's db image, or the empty database --url-env names), reads information_schema.columns and pg_catalog, and refuses a missing table, a missing column, a type that differs once both are normalised to PostgreSQL's format_type names (an enum by its type name), and a nullability that differs. A database column no entity maps is fine. A host that cannot provide the scratch database gets no verdict (exit 2, needs a database), never a green."
5808
+ kinds: [check]
5809
+ gates: [ci]
5810
+ failureCodes: ["BE_ENTITY_SCHEMA_DRIFT"]
5811
+ enforcers:
5812
+ - {kind: hfs, id: db-drift, at: packages/hfs/db/drift.mjs}
5813
+ editions: [lite]
5814
+ editionWhy: "The owner ruling of 2026-10-29 maps lite tables with TypeORM entities while db/migrations/*.sql stays the only DDL and synchronize is false, so only lite entities can drift from a schema they do not create; full entities ship with their own TypeORM migrations."
5815
+ docs: [contexts/code/be/persistence.md]
5816
+ templateNa: "The entities and the migrations are the product's tables; the check judges whichever an app writes, and a template scaffolds no table."
5817
+ proves:
5818
+ - {path: packages/hfs/src/db-drift.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
5819
+ - {path: packages/hfs/src/db-drift-docker.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
5820
+ exemplifiedBy:
5821
+ - {path: packages/hfs/src/db-drift.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
5822
+ witness:
5823
+ - {path: packages/hfs/src/db-drift.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
5824
+ cases:
5825
+ - id: case-1
5826
+ date: "2026-10-29"
5827
+ source: owner
5828
+ quote: "lite persistence moves to TypeORM entities while db/migrations/*.sql stays the only DDL and synchronize is false"
5829
+ 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."
@@ -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.7.0
10
+ version: 3.8.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).
@@ -45,6 +45,8 @@ versioning:
45
45
  # 2026-10-24: lite may serve SSE, and SSE is the default door for one-way push), and lets fe.modules.api hold the SSE stream operations (sse/).
46
46
  # 3.7.0 is a minor (alpha.31): it ADDS the optional app.scripts.integration slot (scripts/integration/*.mjs, the product test-support scripts that only the integration harness runs) and the optional fe.transport.stream slot, the fetch reader of server-sent-events streams (the stream half of the
47
47
  # transport client layer), and lets fe.hooks.swr hold the SWR subscription hooks (api/sse/subscriptions/useSubscribe<X>Swr.ts, one useSWRSubscription per stream operation).
48
+ # 3.8.0 is a minor (urgent release, 2026-10-29): it ADDS the optional fe.dev-server-generated slot (apps/*/AGENTS.md, which `next dev` rewrites) and the
49
+ # 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.
48
50
 
49
51
  # Field meaning, per slot.
50
52
  # id stable name, never changes meaning inside a major.
@@ -56,6 +58,8 @@ versioning:
56
58
  # A slot is optional in the sense of the requirements list when its presence is not required.
57
59
  # tracked tracked (committed) | ignored (may exist, must be gitignored) | external (must not exist in the tree;
58
60
  # goesTo says where it lives).
61
+ # rewrittenBy on an ignored file slot: the dev server that writes the file into the working tree while it runs (`next dev`). The
62
+ # release gate's clean-tree check (release/gate.mjs dirtyPaths) passes over an untracked file such a slot owns, and only it.
59
63
  # tier node of the import direction matrix (tiers below); none = takes no part in import checks;
60
64
  # inherit = the tier of the enclosing owner.
61
65
  # owner true when every directory matching the slot is an owner: the unit cross-owner imports and cycles use.
@@ -254,6 +258,25 @@ ruleParams:
254
258
  "@socket.io/postgres-adapter": [platform/realtime] # the stateless socket.io adapter of a scaled lite back end without Redis
255
259
  "@socket.io/redis-adapter": [platform/realtime] # the sharded socket.io adapter of a scaled back end on the declared Redis
256
260
  pg: [platform/realtime] # the LISTEN connection of the Postgres hub; every other query goes through TypeORM
261
+ # BE-AI-1, BE-AI-3: every model call goes through the ai-router gateway. Its custody owner registers @starci/ai-router with the
262
+ # provider keys; the one LangChain integration owns @langchain/*; no provider SDK, openai client or agent framework is imported.
263
+ "@starci/ai-router": [integrations/ai-gateway]
264
+ "@starci/ai-router/testing": [integrations/ai-gateway, integrations/ai] # the fake provider a spec of either side boots the gateway with
265
+ "@langchain": [integrations/ai] # a scope key: every @langchain/* package
266
+ "@langchain/langgraph": [] # an agent loop runs in the agent runtime, never in code
267
+ "@langchain/community": [] # retrieval reads the app's own persistence through its owner
268
+ langchain: [] # createAgent is an agent loop
269
+ openai: [] # code calls models through @langchain/openai
270
+ "@anthropic-ai/sdk": []
271
+ "@google/genai": []
272
+ "@google/generative-ai": []
273
+ "@mistralai/mistralai": []
274
+ "@openrouter/ai-sdk-provider": []
275
+ "@ai-sdk": []
276
+ ai: []
277
+ cohere-ai: []
278
+ groq-sdk: []
279
+ ollama: []
257
280
  # R87 BE_CQRS_SHAPE no-event-bus: the in-process event vocabulary the rule refuses. `imports` maps a module
258
281
  # specifier to the member names that are an in-process event; `[]` flags the package itself (its emitter packages
259
282
  # are the `[]` owners of infraOwners above). `classes` maps a module specifier to the class names that are an
@@ -1136,6 +1159,16 @@ slots:
1136
1159
  tier: none
1137
1160
  tests: none
1138
1161
  why: produced by `npm run codegen` (prebuild and pretypecheck); never committed
1162
+ - id: fe.dev-server-generated
1163
+ since: 3.8.0
1164
+ profiles: [fe]
1165
+ path: "apps/*/AGENTS.md"
1166
+ presence: optional
1167
+ tracked: ignored
1168
+ rewrittenBy: next dev
1169
+ tier: none
1170
+ tests: none
1171
+ why: the agent notes `next dev` writes at a Next app's root each time it starts; never committed, and never a dirty tree for the release gate
1139
1172
 
1140
1173
  # ----- never in the working tree: caches, scratch, agent output ----------------------------------------
1141
1174
  - id: repo.tool-cache
@@ -1892,7 +1925,7 @@ slots:
1892
1925
  tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
1893
1926
  coverage: required
1894
1927
  budget: {indexExports: 80} # a provider's port surface includes clients, config, options, tokens and errors
1895
- rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING]
1928
+ rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT]
1896
1929
  - id: be.integrations.model
1897
1930
  since: 3.1.0
1898
1931
  profiles: [be]
@@ -1902,7 +1935,7 @@ slots:
1902
1935
  tier: integrations
1903
1936
  tests: none
1904
1937
  coverage: required
1905
- why: an ML or LLM model client is an integration; weights and datasets are never in git
1938
+ why: a self-hosted ML model client is an integration; weights and datasets are never in git, and a language model is called through the ai-router gateway by the integrations/ai LangChain client (BE-AI-1, BE-AI-3)
1906
1939
  rules: [BE_TIER_DIRECTION, BE_INFRA_OWNER, BE_INTEGRATION_SPEC_MISSING]
1907
1940
 
1908
1941
  - id: be.integrations.supabase
@@ -1739,6 +1739,16 @@ BE_WEBHOOK_UNVERIFIED:
1739
1739
  owner: op-retry
1740
1740
  kind: check-finding
1741
1741
 
1742
+ BOOT_PROOF_TIMEOUT:
1743
+ title: "The lite boot proof's worker ran out of its budget twice and reached no verdict"
1744
+ title_vi: "Bằng chứng khởi động lite hết thời gian hai lần, chưa có kết luận"
1745
+ meaning_vi: "Tiến trình chứng minh ghép nối Nest chạy quá ngân sách thời gian riêng của nó (STARCI_BOOT_PROOF_TIMEOUT_MS, lấy từ heavy.bootProofTimeoutSeconds của ops/liveness.yaml) cả lần đầu lẫn lần thử lại. Đây không phải lỗi ghép nối: máy đang quá tải nên chưa phán được."
1746
+ causes_vi:
1747
+ - "Máy chủ đang chạy nhiều lệnh nặng cùng lúc nên trình biên dịch TypeScript của bằng chứng chạy quá chậm"
1748
+ nextStep_vi: "Chạy lại starci app check --wiring khi máy rảnh hơn; nếu vẫn hết thời gian khi máy rảnh, nâng heavy.bootProofTimeoutSeconds trong ops/liveness.yaml."
1749
+ owner: op-retry
1750
+ kind: check-finding
1751
+
1742
1752
  CI_DECLARATION_DRIFT:
1743
1753
  title: "Workflow references drift from CI declarations"
1744
1754
  title_vi: "Workflow lệch khai báo CI"
@@ -5629,3 +5639,14 @@ STACK_TOOLING_REIMPLEMENTED:
5629
5639
  nextStep_vi: "Xóa tệp hoặc lệnh đó và gọi động từ được nêu trong thông báo (starci app stack render, deploy, converge, verify, prune, rollback, prepare, doctor), đặt hằng số của ứng dụng vào khối deploy của application-stacks.yaml."
5630
5640
  owner: op-retry
5631
5641
  kind: check-finding
5642
+
5643
+ BE_ENTITY_SCHEMA_DRIFT:
5644
+ title: "A TypeORM entity column differs from the schema the migrations build"
5645
+ title_vi: "Cột entity TypeORM lệch với schema mà migration tạo ra"
5646
+ meaning_vi: "starci app db drift áp db/bootstrap.sql và mọi migration vào một cơ sở dữ liệu tạm, rồi so từng cột entity với schema đó: bảng hoặc cột không tồn tại, kiểu khác nhau sau khi chuẩn hóa theo tên của PostgreSQL, hoặc khả năng nhận NULL khác nhau."
5647
+ causes_vi:
5648
+ - "Vi phạm R333: entity được sửa mà không có migration tương ứng, hoặc migration đổi bảng mà entity chưa theo kịp."
5649
+ - "Entity khai báo sai kiểu (ví dụ timestamp thay vì timestamptz) hoặc quên nullable cho một cột cho phép NULL."
5650
+ nextStep_vi: "Viết migration mới trong db/migrations để schema có đúng cột đó, hoặc sửa entity cho khớp schema hiện tại, rồi chạy lại starci app db drift cho đến khi xanh."
5651
+ owner: op-retry
5652
+ kind: check-finding
@@ -2,6 +2,9 @@
2
2
  // Dynamic roots need public fixture options: src/tests/fixtures/builders/<app>.builder.ts may export
3
3
  // buildWiringProof(AppModule) => { options, overrides: [{ token, value }], request? }. The runner always chooses
4
4
  // the real root; a fixture cannot substitute an empty module. No environment parser or main.ts runs.
5
+ // The proof worker has its own budget, counted from its spawn: STARCI_BOOT_PROOF_TIMEOUT_MS, which the starci CLI sets from ops/liveness.yaml
6
+ // (heavy.bootProofTimeoutSeconds). A worker that runs out of it reached no verdict: it is retried once, and a second timeout is reported as
7
+ // BOOT_PROOF_TIMEOUT (a warning: the host was too loaded to judge), never as the proof failing (BE_APP_BOOT_PROOF_MISSING).
5
8
  import fs from 'node:fs';
6
9
  import path from 'node:path';
7
10
  import { createRequire } from 'node:module';
@@ -12,6 +15,8 @@ import { isMain } from '../lib/is-main.mjs';
12
15
  import { readJson } from './rules/read.mjs';
13
16
 
14
17
  export const BOOT_PROOF_CODE = 'BE_APP_BOOT_PROOF_MISSING';
18
+ export const BOOT_PROOF_TIMEOUT = 'BOOT_PROOF_TIMEOUT';
19
+ export const BOOT_PROOF_TIMEOUT_ENV = 'STARCI_BOOT_PROOF_TIMEOUT_MS';
15
20
  const WORKER = fileURLToPath(import.meta.url);
16
21
  const DEFAULT_TIMEOUT = 15000;
17
22
  const DI_FAILURES = new Map([
@@ -29,6 +34,18 @@ export const wiringFixturePath = app => `src/tests/fixtures/builders/${app}.buil
29
34
  const refusal = (file, reason) => ({ code: BOOT_PROOF_CODE, level: 'error', path: file,
30
35
  message: `${file}: lite boot proof failed (${reason}); compile the real AppModule with explicit fixture options and infrastructure doubles using starci app check --wiring`, reason });
31
36
 
37
+ const timedOut = (file, budget) => ({ code: BOOT_PROOF_TIMEOUT, level: 'warning', path: file, reason: 'proof worker timed out',
38
+ message: `${file}: the lite boot proof reached no verdict: its worker ran out of its ${Math.round(budget / 1000)} s budget twice (the host is loaded); this is no failure of the proof, rerun starci app check --wiring when the host is quieter` });
39
+
40
+ /** The worker's budget in ms, from its spawn: the CLI's STARCI_BOOT_PROOF_TIMEOUT_MS, else four compile deadlines (at least a minute). */
41
+ export function workerBudget(timeout, env = process.env) {
42
+ const configured = Number(env[BOOT_PROOF_TIMEOUT_ENV]);
43
+ return Number.isInteger(configured) && configured > 0 ? configured : Math.max(60000, timeout * 4) + 2000;
44
+ }
45
+
46
+ /** True when the worker was stopped by its budget (spawnSync's ETIMEDOUT, or the kill signal it sends), not when it ended by itself. */
47
+ const isTimeout = (result) => result?.error?.code === 'ETIMEDOUT' || (result?.status === null && result?.signal != null);
48
+
32
49
  /** Stable diagnostics never forward exception text, source values or child output. */
33
50
  function failureReason(error) {
34
51
  // Nest exceptions inherit Error.name; their constructor identifies the refusal.
@@ -153,8 +170,12 @@ function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, time
153
170
  const fixture = fixtures[app] ?? wiringFixturePath(app);
154
171
  const fixtureExists = fs.existsSync(path.join(beRoot, fixture)) && (!files || files.includes(`${prefix}${fixture}`));
155
172
  const args = [WORKER, '--worker', JSON.stringify({ repoRoot: beRoot, app, fixture: fixtureExists ? fixture : null, timeout })];
156
- const options = { cwd: beRoot, encoding: 'utf8', env: {}, windowsHide: true, timeout: Math.max(60000, timeout * 4) + 2000, maxBuffer: 1024 * 1024 };
157
- const result = run ? run(process.execPath, args, options) : runNode(args, options);
173
+ const budget = workerBudget(timeout);
174
+ const options = { cwd: beRoot, encoding: 'utf8', env: {}, windowsHide: true, timeout: budget, maxBuffer: 1024 * 1024 };
175
+ const spawnWorker = () => (run ? run(process.execPath, args, options) : runNode(args, options));
176
+ let result = spawnWorker();
177
+ if (isTimeout(result)) result = spawnWorker();
178
+ if (isTimeout(result)) return timedOut(file, budget);
158
179
  let proof;
159
180
  try { proof = JSON.parse(result.stdout ?? ''); } catch { /* Output from app source cannot serve as a proof. */ }
160
181
  if (result.status !== 0 || proof?.ok !== true) return refusal(file, SAFE_REASONS.has(proof?.reason) ? proof.reason : WORKER_FAILURE);
@@ -61,7 +61,7 @@
61
61
  // findings fail the check.
62
62
  import fs from 'node:fs';
63
63
  import path from 'node:path';
64
- import { appWiring, BOOT_PROOF_CODE } from './app-wiring.mjs';
64
+ import { appWiring, BOOT_PROOF_CODE, BOOT_PROOF_TIMEOUT } from './app-wiring.mjs';
65
65
  import { coverageFrom as coverageFromReport } from './coverage-from.mjs';
66
66
  import { skillRoot } from '../../engine/runtime-root.mjs';
67
67
  import { ARCHITECTURE_RULE_IDS, checkArchitecture } from './architecture/index.mjs';
@@ -410,7 +410,7 @@ export function withCheckProofs(result, { repoRoot, root = skillRoot, manifest =
410
410
  const proofFindings = Object.values(reports).flatMap(report => report.findings);
411
411
  const why = readWhy(root, [...new Set(proofFindings.map(finding => finding.code))]);
412
412
  // The explicit whole-tree wiring proof supersedes the ordinary finder's scoped result.
413
- const previous = result.findings.filter(finding => !wiring || finding.code !== BOOT_PROOF_CODE);
413
+ const previous = result.findings.filter(finding => !wiring || (finding.code !== BOOT_PROOF_CODE && finding.code !== BOOT_PROOF_TIMEOUT));
414
414
  const findings = [...previous, ...withWhy(judgedFindings(proofFindings, repo.edition ?? 'full', root), why)];
415
415
  const counts = summarize(findings);
416
416
  return { ...result, ...reports, findings, counts, ok: counts.error === 0 };
@@ -87,7 +87,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
87
87
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
88
88
  const strList = (v) => stringList(v);
89
89
 
90
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
90
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'rewrittenBy'];
91
91
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
92
92
  const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
93
93
  /** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
@@ -114,6 +114,7 @@ function appIdentityProblems(slot, at, appScope, scopes) {
114
114
  if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
115
115
  if (profilesInvalid(slot.profiles, appScope, scopes)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
116
116
  else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
117
+ if (slot.rewrittenBy !== undefined && (typeof slot.rewrittenBy !== 'string' || !slot.rewrittenBy.trim() || slot.tracked !== 'ignored' || String(slot.path).endsWith('/'))) bad.push(`${at}: rewrittenBy names the dev server that writes the files of an ignored file slot`);
117
118
  return bad;
118
119
  }
119
120
 
@@ -48,7 +48,7 @@ export const A15_CODES = [
48
48
  'HFS_WORKFLOW_CONTEXT',
49
49
  'CI_STANDARD',
50
50
  'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
51
- 'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_REDIS_PERSISTENCE', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
51
+ 'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BOOT_PROOF_TIMEOUT', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_REDIS_PERSISTENCE', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
52
52
  // codes-end
53
53
  ];
54
54
 
package/src/main.mjs CHANGED
@@ -96,10 +96,11 @@ import { dbMigrateMain } from '../db/migrate.mjs';
96
96
  import { dbPostCutoverMain } from '../db/post-cutover.mjs';
97
97
  import { dbDumpMain } from '../db/dump.mjs';
98
98
  import { dbResetMain } from '../db/reset.mjs';
99
+ import { dbDriftMain } from '../db/drift.mjs';
99
100
  import { codemodMain } from '../codemod/button-intent.mjs';
100
101
 
101
102
  export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage', 'codemod']);
102
- const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
103
+ const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain], ['drift', dbDriftMain]]);
103
104
 
104
105
  const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
105
106
  starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|block|composite|branch|leaf|page|overlay|layout> <name> [options] [--cwd <dir>]
@@ -114,11 +115,13 @@ starci app db dump (--linked | --local | --url-env <NAME>) --out <dir> [--schema
114
115
  starci app db reset --linked --auth keep|drop --storage keep|drop --backup <receipt.json> [--execute] [--json] [--cwd <dir>]
115
116
  starci app db migrate [--execute] [--local | --linked | --url-env <NAME>] [--json] [--cwd <dir>]
116
117
  starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
117
- starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json] [--cwd <dir>]
118
- starci app release push [--execute] [--remote <name>] [--json] [--cwd <dir>]
118
+ starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
119
+ starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json] [--cwd <dir>]
120
+ starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json] [--cwd <dir>]
119
121
  starci app upgrade [--plan] [--cwd <dir>]
120
122
  starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
121
123
  starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
124
+ starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--execute] [--json] [--cwd <dir>]
122
125
  starci app explain <path> [--cwd <dir>] [--json]
123
126
  starci app emit [--cwd <dir>]
124
127
  starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
@@ -342,7 +345,7 @@ async function installedStackMain(cwd) {
342
345
  function databaseMain(argv, io) {
343
346
  const [verb, ...rest] = argv;
344
347
  const dispatch = DATABASE_VERBS.get(verb);
345
- if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate, post-cutover, dump or reset');
348
+ if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate, post-cutover, dump, reset or drift');
346
349
  return dispatch(rest, io);
347
350
  }
348
351
 
package/stack/cli.mjs CHANGED
@@ -14,8 +14,8 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
14
14
 
15
15
  export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
16
16
 
17
- const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push']);
18
- const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding']);
17
+ const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent']);
18
+ const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out']);
19
19
  const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
20
20
 
21
21
  /** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
@@ -71,6 +71,7 @@ const OPTIONS = {
71
71
  deploy: ({ flags, cwd }) => ({
72
72
  images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
73
73
  out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
74
+ urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
74
75
  }),
75
76
  converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
76
77
  verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
@@ -125,6 +126,8 @@ export async function stackMain(argv, io = {}) {
125
126
  if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
126
127
  throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
127
128
  const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
129
+ if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env and --backup-out belong to deploy');
130
+ if (action === 'deploy' && !flags.urgent && (flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
128
131
  const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
129
132
  const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
130
133
  const sshCmd = flags['ssh-cmd'] ?? null;
@@ -132,6 +135,11 @@ export async function stackMain(argv, io = {}) {
132
135
  const toolContext = nativeToolContext(ctx, binding);
133
136
  const result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(toolContext, config, { sshCmd }, call) : await call(toolContext);
134
137
  print(ctx, action, flags, result);
138
+ // A floor step of an urgent deploy that failed is a refusal with its own code (urgent-floor.mjs), printed after the lines that led to it.
139
+ if (result.refused) {
140
+ ctx.stderr(`starci app stack: ${result.refused.code}: ${result.refused.message}\n`);
141
+ return EXIT.refused;
142
+ }
135
143
  return result.ok ? EXIT.ok : EXIT.failed;
136
144
  } catch (error) {
137
145
  if (!(error instanceof StackError)) throw error;
package/stack/deploy.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  // deploy: render, provision, store the release record, pre-pull, `docker stack deploy`, converge, post-deploy tasks (deploy.postDeploy, when declared), verify, accept, prune. A failure at or
3
3
  // after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
4
4
  // Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
5
+ // An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
6
+ // backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails.
5
7
  //
6
8
  // `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
7
9
  // module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
@@ -21,6 +23,7 @@ import { postDeployStack } from './post-deploy.mjs';
21
23
  import { pruneStack } from './prune.mjs';
22
24
  import { parseYaml } from '../runtime/engine/yaml.mjs';
23
25
  import { createReleaseStore } from './release-store.mjs';
26
+ import { urgentDeploy } from './urgent-floor.mjs';
24
27
  import { verifyStack } from './verify.mjs';
25
28
 
26
29
  /** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
@@ -39,8 +42,11 @@ export const DEFAULT_DEPS = Object.freeze({
39
42
  pruneStack,
40
43
  releaseStore: createReleaseStore,
41
44
  ssh: lazy('./remote.mjs', 'ownerSshRunner'),
45
+ floor: {},
42
46
  });
43
47
 
48
+ const fileSha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
49
+
44
50
  const IMAGE_LINE = /^[ \t]+image:[ \t]*(\S+)[ \t]*$/gmu;
45
51
  const QUOTE = /^["']|["']$/gu;
46
52
  const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9./_:@-]*$/u;
@@ -212,7 +218,7 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
212
218
  }
213
219
 
214
220
  /**
215
- * Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd }.
221
+ * Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut }.
216
222
  * skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
217
223
  * inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
218
224
  * Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack } }.
@@ -224,15 +230,26 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
224
230
  const out = options.out ?? made;
225
231
  const cleanups = [];
226
232
  try {
227
- const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, null));
228
- const { releaseId } = inspectRendered(out, config);
229
- if (options.execute !== true) {
233
+ // The local render and its inspection: a release that cannot render is refused before anything remote happens.
234
+ const render = async () => {
235
+ const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, null));
236
+ return { rendered, releaseId: inspectRendered(out, config).releaseId };
237
+ };
238
+ const dryRun = async () => {
239
+ const { rendered, releaseId } = await render();
230
240
  const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
231
241
  const plan = options.skipPublic
232
242
  ? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept`
233
243
  : `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted`;
234
244
  return { ok: true, lines: [...(rendered.lines ?? []), plan], data: { releaseId, steps: [{ step: 'render', ok: true }], executed: false } };
245
+ };
246
+ if (options.urgent === true) {
247
+ return await urgentDeploy(ctx, config, options, {
248
+ render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: () => deployExecute(ctx, config, options, d, out, cleanups), sha256: fileSha256,
249
+ }, d.floor);
235
250
  }
251
+ if (options.execute !== true) return await dryRun();
252
+ await render();
236
253
  return await deployExecute(ctx, config, options, d, out, cleanups);
237
254
  } finally {
238
255
  cleanups.forEach((close) => close());