@patronage/factory-ci 1.0.0-alpha.34 → 1.0.0-alpha.35

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.
package/README.md CHANGED
@@ -688,6 +688,12 @@ const result = await runAlchemyLifecycle({
688
688
 
689
689
  The consumer passes typed policy and two capabilities. `state` is the store from `@patronage/alchemy-d1-state`: `snapshotStage`, `get`, and `acquireStageOwnership`, whose lease carries `renew`, `release`, `deleteEmptyStage` and `childEnvironment`, matched structurally, so this package learns no table, account, or SQL. `entry` is what `executeAlchemyEntry` needs minus what the lifecycle decides: the arguments are the lifecycle's, stdio is always piped so the plan can be read, and a `bundle` is built once up front, with every child launched in `cwd`, then the bundle root, then this process's directory — the same chain `executeAlchemyEntry` resolves. A bundle that cannot build is `PREPARATION_FAILED` and nothing else: the bundler's own diagnostics are silenced at the source. There is no `redact` and no echo because there is no sink: each child's captured output is parsed and dropped, written nowhere, so nothing in it can reach a job log or an evidence file through this operation. Every child's environment carries the stage lease; see ownership below.
690
690
 
691
+ The optional `childDiagnostics: { path, redact }` is the one way any of that captured output reaches a file. Omitted — the default, and byte-identical to the behavior above — nothing is written and nothing changes. Set, every child of the operation (both dry runs, the apply, the destroy) has its stdout and its stderr appended to `path` under a header naming the operation, the child, and the stage. Each stream is passed **whole** through `redact`, not a line at a time, because a declared value can itself span lines — a PEM key, a pasted service-account JSON — and a per-line pass would find no line holding the whole value and write every one of them verbatim. The consumer chooses the path and supplies the redactor — `secretNamePolicy({ names, values })` builds the usual one — because the secret names are project policy and this package holds none. The record is a copy: `redact` never runs over what the child hands back, so the plan text parsed by `parsePlanEffects` and hashed for a first deploy is the child's own bytes. A `CHILD_FAILED` from such a run carries `childDiagnosticsPath`, the path and never the output, so an operator knows where to look.
692
+
693
+ A record that cannot be written is `CHILD_DIAGNOSTICS_UNWRITABLE`, never a misreported child failure, and **when** it is reported depends on what has already happened to the stage. The file is opened and headed before each child starts, so while no mutating child has run — an unwritable path at the first child, a redactor that throws on a dry run — the run is refused up front with the stage still untouched. Once the apply or the destroy child has started, the failure is retained and reported only after the operation has run to its end, the way `LEASE_RELEASE_FAILED` is: the mutation succeeded, and abandoning the convergence check over a missing record would invite an operator to destroy and retry a stage that is fine. So a good deploy still converges and is still checked, and the run then ends in `CHILD_DIAGNOSTICS_UNWRITABLE` with the mutation certainty it actually reached. A child that failed is reported ahead of a record that could not be written, because the child is the finding. The file is only as redacted as the declared name list makes it — a value the project did not declare, and any re-encoded form of one it did, reaches the file verbatim — so it belongs outside the repository and outside anything uploaded as an artifact.
694
+
695
+ The optional `entry.envFile` selects the dotenv file for every child, including both dry runs. Its path follows Alchemy’s normal resolution from the child working directory. Omit it to preserve Alchemy’s default `.env` behavior. A consumer that requires environment-only credentials can supply its own private empty file for the operation’s lifetime; file creation and cleanup remain consumer policy.
696
+
691
697
  The sequence, per operation:
692
698
 
693
699
  - **`plan`** takes the stage lease, reads the persisted state, runs `deploy --dry-run`, reads the state again, and refuses with `STATE_CHANGED` if any resource record or the stack output differs — all under the one lease, so no other writer can reach the stage while the child plans. That refusal wins over the child's own exit status, because a planning child that wrote is the more serious finding. On an empty protected stage with a `firstDeploy` policy the result carries `planHash`, the value an operator authorizes.
@@ -698,7 +704,7 @@ Ownership follows #1074 and #1107. Each operation runs under **one** stage lease
698
704
 
699
705
  The consumer's entry point passes its environment to the adapter: `d1State({ ..., ownership: { stack, stage, holder, adoptFrom: process.env } })`. That is the whole wiring, the same for a plan, a deploy and a destroy, and the same when an operator runs the entry by hand with no parent (the adapter then acquires as usual). Nothing in the child is trusted to opt in: while the parent holds the stage, a child whose state layer takes its own lease is refused at its first state call, and one that takes none is refused at its first write, by the store's fence. The token crosses the process boundary only through that environment; it is on no result, no failure, and no type this package exports. The store fences its rows, not Cloudflare: a provider call that reached Cloudflare before a lease was lost is not undone, and the refused state write is how the loss surfaces (see the adapter's README). The lifecycle does not reimplement any of this; it consumes it.
700
706
 
701
- Every failure is an `AlchemyLifecycleError` with an enumerated `code`, `phase` (`request`, `admission`, `state`, `execution`, `postcondition`), and `mutation` (`not-started`, `read-only-started`, `mutation-may-have-occurred`). The message is the fixed sentence `LIFECYCLE_FAILURE_MESSAGES[code]` and nothing else; no failure carries a `cause`. Nothing a child printed, nothing the store said, and nothing a credential resolver quoted can reach an evidence sink through this error. After a deploy or destroy child was started, no failure reports `not-started`.
707
+ Every failure is an `AlchemyLifecycleError` with an enumerated `code`, `phase` (`request`, `admission`, `state`, `execution`, `postcondition`), and `mutation` (`not-started`, `read-only-started`, `mutation-may-have-occurred`). The message is the fixed sentence `LIFECYCLE_FAILURE_MESSAGES[code]` and nothing else; no failure carries a `cause`. The one field that says anything about a child's output is `childDiagnosticsPath`, present on `CHILD_FAILED` and `CHILD_DIAGNOSTICS_UNWRITABLE` when the run configured `childDiagnostics`: it is the path the consumer chose, never the output, and never part of the message. Nothing a child printed, nothing the store said, and nothing a credential resolver quoted can reach an evidence sink through this error. After a deploy or destroy child was started, no failure reports `not-started`.
702
708
 
703
709
  The plan is read once, by `parsePlanEffects`, from the lines Alchemy's `formatPlanLines` prints: one `Plan:` summary, one `[fqn] action` row per resource, one `[fqn/name] action` row per binding of that resource, and one `[fqn] run|drop [action]` row per task. Every line arrives wrapped in Effect's pretty log prefix (`[HH:MM:SS.mmm] INFO (#1): `) and, when the child inherits `FORCE_COLOR`, in colour; both are stripped first. The id in a row is the resource's fully qualified name, the identity it is persisted under, so every check that binds a row to a resource resolves it to the persisted FQN it names exactly — a planned create to that FQN recorded after the apply and not before it, a convergence row to that FQN recorded for it — and refuses when it is absent. A plan that prints one FQN twice cannot come from the renderer and is refused (`AMBIGUOUS_RESOURCE`). The summary counts `create`, `update`, `adopted`, `replace`, `delete` and `orphaned` resources, then `N binding changes` and `N tasks`; `noop` rows print but are not counted. A binding row extends its resource's FQN by `/name`, and so does a resource inside a namespace, so when a resource named `Parent` is followed by rows tagged `Parent/...` the renderer gives no evidence which are its bindings and which are namespaced resources; the summary's counts decide, the one reading that adds up is taken, a plan the counts can place in more than one way is refused (`ambiguous-rows`; the counts are global, so two such resources in one plan couple and are refused together), the search is memoised so a plan of many such resources costs rows times counts and never the product of their readings, and a `noop` row under such a resource — placed by no count and read by no decision — is read as a binding. `orphaned` (a retained resource leaving the stack) is refused wherever `delete` is; `adopted` (a resource the provider already finds, planned only for a record with no prior state) is permitted wherever `create` is. The adapter is bound to `PLAN_OUTPUT_ALCHEMY_VERSION`, which is `ALCHEMY_BASELINE.alchemy`, and the lifecycle refuses an installed Alchemy of any other version (`UNSUPPORTED_ALCHEMY_VERSION`) before any child runs, so a renderer change cannot be read as a plan with different effects. The version is read from the manifest of the package that owns the CLI the children run — `alchemy/bin/alchemy.js` is resolved from the consumer and the nearest `package.json` named `alchemy` above it is read from disk — because `alchemy/package.json` is not on the package's `exports` map and cannot be resolved as a specifier. The adapter fails on output it cannot account for — no summary, two summaries, an unknown action, a summary it cannot parse, a count that does not match the rows, rows the counts place in more than one way — and reports only the reason, never the output. An effect it could not account for is never reported as zero.
704
710
 
@@ -717,7 +723,7 @@ import {
717
723
 
718
724
  Four checks that surround an Alchemy run. Paitronage and firedup each built the first one and the last one, and paitronage built all four, so the check repeats and the policy does not: the account, the permission list, the eligible stages, the authorized plans, the state store, and the secret names are all inputs.
719
725
 
720
- `credentialPreflight({ accountId, environment, requiredPermissionGroups, resolveCredential, resourcesFor?, source })` proves the deploy holds the credential it claims to. `source: "hosted"` requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in the environment and requires the account to be the one the deploy allows; `source: "trusted-local"` requires that neither is there, because a local run resolves its credential from the project's own secret manager and an ambient token means the run is not the run it says it is. `resolveCredential` is where that resolution happens — a 1Password read, a hosted environment read, whatever the project uses. The token is then verified against Cloudflare, matched to the reviewed policy document by token id, and reduced to permission-group names, which must equal `requiredPermissionGroups` with nothing missing, extra, or repeated. `resourcesFor` names the exact resource identifiers a group may be scoped to and defaults to the account resource, which is how a zone-scoped group is declared. On the hosted path the resolved token must be the environment token, compared and never named, because that is the token the deploy will use. A `CloudflareCredentialUnavailableError` means Cloudflare could not be asked, or the project's own resolver failed; a resolver failure is reported as one fixed message with no `cause`, because a secret-manager error can quote the value it was reading. Every other failure means the credential is wrong. No error and no result carries a credential value.
726
+ `credentialPreflight({ accountId, environment, requiredPermissionGroups, resolveCredential, resourcesFor?, source, tokenKind? })` proves the deploy holds the credential it claims to. `source: "hosted"` requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in the environment and requires the account to be the one the deploy allows; `source: "trusted-local"` requires that neither is there, because a local run resolves its credential from the project's own secret manager and an ambient token means the run is not the run it says it is. `resolveCredential` is where that resolution happens — a 1Password read, a hosted environment read, whatever the project uses. `tokenKind` selects the verification endpoint: `"account"` (the default) uses `/accounts/<accountId>/tokens/verify`; `"user"` uses `/user/tokens/verify`. Token ownership is independent of credential source and permission scope: an account-scoped user token still selects `"user"`. Unknown kinds refuse before resolving credentials; there is no endpoint fallback. Both kinds enforce the same account, receipt, permission, and resource checks. The token is then verified against Cloudflare, matched to the reviewed policy document by token id, and reduced to permission-group names, which must equal `requiredPermissionGroups` with nothing missing, extra, or repeated. `resourcesFor` names the exact resource identifiers a group may be scoped to and defaults to the account resource, which is how a zone-scoped group is declared. On the hosted path the resolved token must be the environment token, compared and never named, because that is the token the deploy will use. A `CloudflareCredentialUnavailableError` means Cloudflare could not be asked, or the project's own resolver failed; a resolver failure is reported as one fixed message with no `cause`, because a secret-manager error can quote the value it was reading. Every other failure means the credential is wrong. No error and no result carries a credential value.
721
727
 
722
728
  `firstDeployAuthorization({ authorizedHashes, planText, sourceSha, stack, stage })` binds the one deploy no earlier state can constrain. The plan, the target, and the reviewed source hash to one value, and that value must appear on a list the project owns. `sourceSha` is required. A full lowercase 40-character SHA binds the plan to that commit; `null` binds the plan to no commit, and that is the project's explicit choice rather than a field it forgot — an omitted field and a deliberate `null` are different decisions, so only the second one is spellable. Anything else, an empty string included, is refused. It fails closed on an empty list, a list entry that is not a sha256 hash, an empty plan, plan output that is not well-formed, plan output the plan adapter cannot read, an unlisted plan, and a stack, stage, or source SHA carrying surrounding whitespace — padding would mint a second hash for one target. The refusal names the hash to review, never the plan. `firstDeployPlanHash` computes the same value on its own, for the run that produces a plan for an operator to read.
723
729
 
@@ -877,3 +883,9 @@ Attended and hand-cut, on the same terms as `@patronage/alchemy-d1-state`: bump
877
883
  ## License
878
884
 
879
885
  MIT.
886
+
887
+ ### Hosted preview receipts
888
+
889
+ `factoryPreviewReceiptWorkflow({ target: "preview:app" })` supplies a `beginStep`, `completionSteps`, `invalidateStep`, and job `permissions` for `psf preview:receipt`. Place `beginStep` before the selected target's first mutation, then the consumer deploy and smoke steps, then `completionSteps`. Keep the bind step name intact. Apply the target selection guard to the begin step; completion requires its nonempty intent output. Canceled runs leave the intent pending. Place `invalidateStep` before cleanup, including cleanup of a failed deployment that never registered a pass.
890
+
891
+ This artifact does not select targets, deploy, smoke, or certify current health. The consumer's reviewed default-branch profile declares publisher workflow paths, job names/IDs, required deploy/smoke step names, cleanup paths, and targets under `extensions.hostedPreviewReceipts`. The CLI reads that profile from GitHub at an immutable default-branch SHA. Candidate workflows must have the same blob as that reviewed version for a receipt to be trusted. See the software-factory command reference for the profile shape and ordered rollout. This source addition does not deploy or adopt the contract in Firedup.
@@ -1,3 +1,3 @@
1
1
  {
2
- "fingerprint": "460142f88d3bbc5b122a0c6235c0830ab3824b3e2501894466f73418c717af80"
2
+ "fingerprint": "618c9d364409977735c10e032d81e1ae70aedfb4a00c00945537ed1135d10f79"
3
3
  }
@@ -1,4 +1,4 @@
1
- import { n as ExecuteAlchemyEntryOptions } from "../execute-alchemy-entry-DB8fFZTu.js";
1
+ import { n as ExecuteAlchemyEntryOptions } from "../execute-alchemy-entry-B397GSk5.js";
2
2
  import * as Alchemy from "alchemy";
3
3
  import * as Effect from "effect/Effect";
4
4
  import * as Layer from "effect/Layer";
@@ -334,13 +334,16 @@ declare const stagePolicy: (table: StagePolicyTable) => StagePolicy;
334
334
  * repeat is the account, the permission list, the resource scope, and where
335
335
  * the credential comes from, so every one of those is an input.
336
336
  *
337
- * **No credential value is ever printed.** Errors name environment variables,
338
- * permission groups, and resource identifiers. They never carry a token, a
339
- * policy document, or any part of one. The result carries the token id, which
340
- * Cloudflare's own verification endpoint returns and which is not a secret.
337
+ * **No credential value is ever printed.** Errors name environment variables and
338
+ * missing groups from the caller-owned permission requirements. Untrusted API
339
+ * status, receipt group names, and resource identifiers are never reflected.
340
+ * The result carries the token id, which Cloudflare's own verification endpoint
341
+ * returns and which is not a secret.
341
342
  */
342
343
  /** Where the deploy's Cloudflare credential comes from. */
343
344
  type CredentialSource = "hosted" | "trusted-local";
345
+ /** Ownership of the token, independent of where its value is resolved. */
346
+ type CloudflareTokenKind = "account" | "user";
344
347
  /**
345
348
  * The environment variables a hosted deploy carries and a trusted-local
346
349
  * deploy must not. A trusted-local run resolves its credential from the
@@ -385,6 +388,8 @@ interface CredentialPreflightOptions {
385
388
  */
386
389
  readonly resourcesFor?: (permissionGroup: string) => readonly string[];
387
390
  readonly source: CredentialSource;
391
+ /** Defaults to account-owned token verification. No endpoint fallback is attempted. */
392
+ readonly tokenKind?: CloudflareTokenKind;
388
393
  }
389
394
  interface CredentialPreflightResult {
390
395
  readonly accountId: string;
@@ -429,7 +434,8 @@ declare const credentialPreflight: ({
429
434
  requiredPermissionGroups,
430
435
  resolveCredential,
431
436
  resourcesFor,
432
- source
437
+ source,
438
+ tokenKind
433
439
  }: CredentialPreflightOptions) => Promise<CredentialPreflightResult>;
434
440
  //#endregion
435
441
  //#region src/alchemy/destroy-postcondition.d.ts
@@ -666,7 +672,10 @@ declare const secretNamePolicy: ({
666
672
  * sentence looked up by code. Nothing a child process printed, nothing a
667
673
  * state store said, and nothing a credential resolver quoted ever reaches
668
674
  * the message, and no failure carries a `cause`. Evidence sinks (`--json`,
669
- * the job summary) can print any field of this error verbatim.
675
+ * the job summary) can print any field of this error verbatim. The one thing
676
+ * a failure may say about a child's output is where a consumer asked for it
677
+ * to be written (`childDiagnosticsPath`), which is a path the consumer chose
678
+ * and never the output.
670
679
  */
671
680
  type AlchemyLifecycleOperation = "plan" | "deploy" | "destroy";
672
681
  /** Where in the sequence the failure was raised. */
@@ -678,13 +687,19 @@ type AlchemyLifecyclePhase = /** The request itself or the policy is malformed.
678
687
  * or destroy child was started, so the stage may differ from before.
679
688
  */
680
689
  type MutationCertainty = "not-started" | "read-only-started" | "mutation-may-have-occurred";
681
- type AlchemyLifecycleFailureCode = "INVALID_REQUEST" | "INVALID_POLICY" | "UNSUPPORTED_ALCHEMY_VERSION" | "UNKNOWN_STAGE" | "AMBIGUOUS_STAGE" | "PROTECTED_STAGE" | "UNKNOWN_STACK" | "STAGE_NOT_OWNED" | "PREPARATION_FAILED" | "STAGE_HELD" | "OWNERSHIP_LOST" | "LEASE_RELEASE_FAILED" | "STATE_STORE_UNAVAILABLE" | "STATE_STORE_INVALID" | "STATE_CHANGED" | "RESOURCE_IDENTITY_MISMATCH" | "FIRST_DEPLOY_UNAUTHORIZED" | "EFFECT_NOT_PERMITTED" | "UNKNOWN_EFFECT" | "AMBIGUOUS_RESOURCE" | "CHILD_FAILED" | "DEPLOY_INCOMPLETE" | "NOT_CONVERGED" | "DESTROY_RESIDUE";
690
+ type AlchemyLifecycleFailureCode = "INVALID_REQUEST" | "INVALID_POLICY" | "UNSUPPORTED_ALCHEMY_VERSION" | "UNKNOWN_STAGE" | "AMBIGUOUS_STAGE" | "PROTECTED_STAGE" | "UNKNOWN_STACK" | "STAGE_NOT_OWNED" | "PREPARATION_FAILED" | "STAGE_HELD" | "OWNERSHIP_LOST" | "LEASE_RELEASE_FAILED" | "STATE_STORE_UNAVAILABLE" | "STATE_STORE_INVALID" | "STATE_CHANGED" | "RESOURCE_IDENTITY_MISMATCH" | "FIRST_DEPLOY_UNAUTHORIZED" | "EFFECT_NOT_PERMITTED" | "UNKNOWN_EFFECT" | "AMBIGUOUS_RESOURCE" | "CHILD_FAILED" | "CHILD_DIAGNOSTICS_UNWRITABLE" | "DEPLOY_INCOMPLETE" | "NOT_CONVERGED" | "DESTROY_RESIDUE";
682
691
  /**
683
692
  * The one sentence each code is reported with. The table is the whole
684
693
  * vocabulary: a message that is not in it cannot be raised.
685
694
  */
686
695
  declare const LIFECYCLE_FAILURE_MESSAGES: Readonly<Record<AlchemyLifecycleFailureCode, string>>;
687
696
  interface AlchemyLifecycleFailureFields {
697
+ /**
698
+ * The file the run's child output was being written to, on a child
699
+ * failure of a run that asked for one. It is a path, so an operator knows
700
+ * where to look; the output itself is never here and never in the message.
701
+ */
702
+ readonly childDiagnosticsPath?: string;
688
703
  readonly code: AlchemyLifecycleFailureCode;
689
704
  readonly mutation: MutationCertainty;
690
705
  readonly operation: AlchemyLifecycleOperation;
@@ -697,6 +712,7 @@ interface AlchemyLifecycleFailureFields {
697
712
  }
698
713
  /** A sanitized lifecycle failure. See the module comment. */
699
714
  declare class AlchemyLifecycleError extends Error implements AlchemyLifecycleFailureFields {
715
+ readonly childDiagnosticsPath?: string;
700
716
  readonly code: AlchemyLifecycleFailureCode;
701
717
  readonly mutation: MutationCertainty;
702
718
  readonly operation: AlchemyLifecycleOperation;
@@ -797,6 +813,59 @@ declare class PlanEffectsError extends Error {
797
813
  */
798
814
  declare const parsePlanEffects: (output: string) => PlanEffects;
799
815
  //#endregion
816
+ //#region src/alchemy/child-diagnostics.d.ts
817
+ /**
818
+ * The optional record of what an Alchemy child printed (#1260).
819
+ *
820
+ * `runAlchemyLifecycle` reports a failed child as `CHILD_FAILED` and nothing
821
+ * else: the child's output is parsed and dropped, so no secret in it can
822
+ * reach a job log or an evidence file through the operation. That is the
823
+ * right default and it stays the default. It also leaves an operator with a
824
+ * failure code and no record of what failed, which twice cost a paitronage
825
+ * investigation its evidence.
826
+ *
827
+ * This is the opt-in other half: a consumer that names a file gets every
828
+ * child's streams appended to it, each stream passed whole through the
829
+ * consumer's own redactor, under a header naming the child. The consumer chooses the path
830
+ * and owns the redactor; this module never picks either, and nothing here
831
+ * changes what the lifecycle decides.
832
+ *
833
+ * The file is opened, and the header written, **before** the child starts,
834
+ * so an unwritable path is known before anything runs and a parent that dies
835
+ * mid-child still leaves the marker for the child that was in flight. The
836
+ * streams themselves arrive after the child exits, because the child is
837
+ * captured (`spawnSync`), not streamed.
838
+ *
839
+ * Nothing here throws into the lifecycle's control flow once the handle is
840
+ * open. A write failure is retained and reported by the caller under its own
841
+ * failure code, because a record that could not be written is a different
842
+ * fact from a child that failed, and reporting it as the second one would
843
+ * put a false cause in front of the operator this file exists for. When the
844
+ * caller reports it — up front, or only after the operation has run to the
845
+ * end — is the caller's rule, not this module's; see `runAlchemyLifecycle`.
846
+ */
847
+ interface ChildDiagnostics {
848
+ /**
849
+ * The file every child's output is appended to. The consumer chooses it,
850
+ * and it belongs outside the repository and outside any uploaded artifact:
851
+ * redaction covers the declared secret names and nothing else, so a value
852
+ * the consumer did not declare reaches this file verbatim.
853
+ */
854
+ readonly path: string;
855
+ /**
856
+ * Applied to each captured stream **as one piece of text** before it
857
+ * reaches the file. The consumer owns it; `secretNamePolicy({ names,
858
+ * values })` builds the usual one.
859
+ *
860
+ * Whole text, not a line at a time, because a declared value can itself
861
+ * span lines — a PEM private key, a pasted service-account JSON — and a
862
+ * per-line pass would find no line holding the whole value and write every
863
+ * one of them verbatim. A redactor that works line by line is still
864
+ * correct here; one that needs the whole text only works here.
865
+ */
866
+ readonly redact: (text: string) => string;
867
+ }
868
+ //#endregion
800
869
  //#region src/alchemy/lifecycle.d.ts
801
870
  interface AlchemyLifecycleRequest {
802
871
  readonly operation: AlchemyLifecycleOperation;
@@ -920,16 +989,47 @@ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K>
920
989
  * The entry capability: what `executeAlchemyEntry` needs minus what the
921
990
  * lifecycle decides. Arguments are the lifecycle's; stdio is always piped so
922
991
  * the plan can be read; the capture limit is `EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER`.
923
- * There is no `redact` because there is no sink: the captured output is
924
- * parsed and dropped. It is written nowhere, so nothing in it can reach a
925
- * job log or an evidence file through this operation.
992
+ * There is no `redact` because `redact` shapes what the child hands back,
993
+ * and the plan text must be the child's own bytes. There is no `onCaptured`
994
+ * for the same reason in the other direction: the sink is the lifecycle's,
995
+ * so a consumer-supplied one would be dropped, and an option that
996
+ * type-checks and does nothing is worse than one that does not exist. By
997
+ * default the captured output is parsed and dropped, written nowhere, so
998
+ * nothing in it can reach a job log or an evidence file through this
999
+ * operation; a consumer that wants a record opts in with `childDiagnostics`,
1000
+ * which owns its own redactor and its own file.
926
1001
  */
927
- type AlchemyLifecycleEntry = DistributiveOmit<ExecuteAlchemyEntryOptions, "args" | "stdio" | "maxBuffer" | "redact">;
1002
+ type AlchemyLifecycleEntry = DistributiveOmit<ExecuteAlchemyEntryOptions, "args" | "onCaptured" | "stdio" | "maxBuffer" | "redact"> & {
1003
+ /** Explicit dotenv file for every child; omitted preserves Alchemy defaults. */readonly envFile?: string;
1004
+ };
928
1005
  interface RunAlchemyLifecycleOptions {
929
1006
  readonly request: AlchemyLifecycleRequest;
930
1007
  readonly policy: AlchemyLifecyclePolicy;
931
1008
  readonly state: StageStateStore;
932
1009
  readonly entry: AlchemyLifecycleEntry;
1010
+ /**
1011
+ * Opt in to keeping what the children printed (#1260). Omitted, the
1012
+ * captured output is parsed and dropped, exactly as before: this is the
1013
+ * only way any of it reaches a file through this operation.
1014
+ *
1015
+ * Set, every child of the operation — the dry runs, the apply, the
1016
+ * destroy — has both streams appended to `path`, each stream passed whole
1017
+ * through `redact` (never a line at a time, so a declared value that
1018
+ * spans lines is covered), under a header naming the child. The consumer
1019
+ * chooses the
1020
+ * path and supplies the redactor; the lifecycle chooses neither and holds
1021
+ * no secret-name list. `secretNamePolicy({ names, values })` from this
1022
+ * subpath builds the redactor from the names the consumer declares.
1023
+ *
1024
+ * The record is a copy: the plan text the operation parses and hashes is
1025
+ * the child's own output, untouched by `redact`. Writing it changes no
1026
+ * exit status and no decision here, and a write that fails is reported as
1027
+ * `CHILD_DIAGNOSTICS_UNWRITABLE` rather than as a child failure — up front
1028
+ * while nothing has been mutated, and otherwise only after the operation
1029
+ * has run to its end, so a record that could not be kept never abandons a
1030
+ * good apply before its convergence check.
1031
+ */
1032
+ readonly childDiagnostics?: ChildDiagnostics;
933
1033
  }
934
1034
  interface AlchemyLifecycleResult {
935
1035
  readonly operation: AlchemyLifecycleOperation;
@@ -985,6 +1085,15 @@ interface AlchemyLifecycleResult {
985
1085
  *
986
1086
  * Every failure is an `AlchemyLifecycleError`. After a deploy or destroy
987
1087
  * child was started, no failure reports `not-started`.
1088
+ *
1089
+ * `childDiagnostics` is the one opt-in that keeps what the children printed:
1090
+ * both streams of every child, each passed whole through the consumer's own
1091
+ * redactor, are appended to the consumer's own file under a per-child
1092
+ * header, and a `CHILD_FAILED` then names that path so an operator knows
1093
+ * where to look. Nothing about the operation changes otherwise: a record
1094
+ * that cannot be written refuses the run only while nothing has been
1095
+ * mutated, and after that is reported as `CHILD_DIAGNOSTICS_UNWRITABLE` once
1096
+ * the operation has finished, never in place of its postconditions.
988
1097
  */
989
1098
  declare const runAlchemyLifecycle: (options: RunAlchemyLifecycleOptions) => Promise<AlchemyLifecycleResult>;
990
1099
  //#endregion
@@ -1062,4 +1171,4 @@ declare const deleteStageRows: (state: StateService, target: StageTarget) => Pro
1062
1171
  /** The specifier a consumer imports this surface by. */
1063
1172
  declare const ALCHEMY_SUBPATH = "@patronage/factory-ci/alchemy";
1064
1173
  //#endregion
1065
- export { ALCHEMY_SUBPATH, type AlchemyLifecycleEntry, AlchemyLifecycleError, type AlchemyLifecycleFailureCode, type AlchemyLifecycleFailureFields, type AlchemyLifecycleOperation, type AlchemyLifecyclePhase, type AlchemyLifecyclePolicy, type AlchemyLifecycleRequest, type AlchemyLifecycleResult, type AssertDestroyLeftNothingOptions, type AssertRetainedIdentitiesOptions, type AssertUrlImpliesAuthOptions, type AuthBindingNameMatcher, type CloudflareCredential, CloudflareCredentialUnavailableError, type CredentialPreflightOptions, type CredentialPreflightResult, type CredentialSource, type EvaluateStackInput, type EvaluatedBinding, type EvaluatedGraph, type EvaluatedResource, type FirstDeployAuthorization, type FirstDeployAuthorizationOptions, type FirstDeployPlanHashOptions, type FirstDeployPolicy, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, type MutationCertainty, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, type PlanAction, type PlanEffects, PlanEffectsError, type PlanEffectsRejection, type PlanResourceEffect, type PlanTaskAction, type PlanTaskEffect, RetainedIdentityError, type RetainedIdentityMarker, type RetainedIdentitySection, type RetainedIdentityViolation, type RetainedIdentityViolationReason, type RunAlchemyLifecycleOptions, SECRET_REDACTION_PLACEHOLDER, type SecretNamePolicyOptions, type StackOutputOccupancy, type StackStageOwnership, type StageCleanupOutcome, type StageLease, type StageMatcher, type StagePolicy, StagePolicyError, type StagePolicyErrorCode, type StagePolicyTable, type StageSnapshotView, type StageStateReader, type StageStateStore, type StageStateTarget, type StageTarget, type StateSnapshot, type StateSnapshotResource, type StateSnapshotScope, type UrlImpliesAuthAllowance, UrlImpliesAuthError, type UrlImpliesAuthViolation, type UrlImpliesAuthViolationReason, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, deleteStageRows, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, inventoryStages, isAlchemyLifecycleError, isLocalEmulationStage, localEmulationEnvironment, parsePlanEffects, parseStateSnapshot, readStageSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };
1174
+ export { ALCHEMY_SUBPATH, type AlchemyLifecycleEntry, AlchemyLifecycleError, type AlchemyLifecycleFailureCode, type AlchemyLifecycleFailureFields, type AlchemyLifecycleOperation, type AlchemyLifecyclePhase, type AlchemyLifecyclePolicy, type AlchemyLifecycleRequest, type AlchemyLifecycleResult, type AssertDestroyLeftNothingOptions, type AssertRetainedIdentitiesOptions, type AssertUrlImpliesAuthOptions, type AuthBindingNameMatcher, type ChildDiagnostics, type CloudflareCredential, CloudflareCredentialUnavailableError, type CloudflareTokenKind, type CredentialPreflightOptions, type CredentialPreflightResult, type CredentialSource, type EvaluateStackInput, type EvaluatedBinding, type EvaluatedGraph, type EvaluatedResource, type FirstDeployAuthorization, type FirstDeployAuthorizationOptions, type FirstDeployPlanHashOptions, type FirstDeployPolicy, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, type MutationCertainty, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, type PlanAction, type PlanEffects, PlanEffectsError, type PlanEffectsRejection, type PlanResourceEffect, type PlanTaskAction, type PlanTaskEffect, RetainedIdentityError, type RetainedIdentityMarker, type RetainedIdentitySection, type RetainedIdentityViolation, type RetainedIdentityViolationReason, type RunAlchemyLifecycleOptions, SECRET_REDACTION_PLACEHOLDER, type SecretNamePolicyOptions, type StackOutputOccupancy, type StackStageOwnership, type StageCleanupOutcome, type StageLease, type StageMatcher, type StagePolicy, StagePolicyError, type StagePolicyErrorCode, type StagePolicyTable, type StageSnapshotView, type StageStateReader, type StageStateStore, type StageStateTarget, type StageTarget, type StateSnapshot, type StateSnapshotResource, type StateSnapshotScope, type UrlImpliesAuthAllowance, UrlImpliesAuthError, type UrlImpliesAuthViolation, type UrlImpliesAuthViolationReason, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, deleteStageRows, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, inventoryStages, isAlchemyLifecycleError, isLocalEmulationStage, localEmulationEnvironment, parsePlanEffects, parseStateSnapshot, readStageSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };