@patronage/factory-ci 1.0.0-alpha.24 → 1.0.0-alpha.26

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
@@ -6,7 +6,9 @@ The CI and deploy building blocks that already repeat across Patronage **factory
6
6
  pnpm add -E @patronage/factory-ci
7
7
  ```
8
8
 
9
- It is a **pure library**. There is no `bin`: anything invocable belongs to `psf`. There is no `alchemy` or `effect` dependency of any kind — not a dependency, not a peer dependency — so the package can never pull a second copy of either into a consumer's graph. `esbuild` is its own dependency, which is what lets consumers delete their local arrangements for finding one.
9
+ It is a **pure library**. There is no `bin`: anything invocable belongs to `psf`. `esbuild` is its own dependency, which is what lets consumers delete their local arrangements for finding one.
10
+
11
+ `alchemy` and `effect` are **optional peer dependencies** (ADR 0031). A peer resolves from the consumer's own installation, so this package still cannot pull a second copy of either into a consumer's graph — and a second copy is the thing that breaks both silently, because Alchemy's resource registry and Effect's service tags are identity-sensitive. The package root imports neither; every Alchemy-importing helper ships from the `@patronage/factory-ci/alchemy` subpath. A consumer that never imports that subpath installs neither package and sees no warning.
10
12
 
11
13
  ## What it is for
12
14
 
@@ -61,22 +63,50 @@ workflow({/* caller-owned jobs and topology */}).writeOrLint({
61
63
  });
62
64
  ```
63
65
 
64
- The **runner is not this package's business**. Jobs, runners, permissions, workflow topology, and deploy policy remain with the caller. The returned values are plain structural objects; this package does not depend on gagen.
65
-
66
- Candidate lifecycle support follows the same boundary. Use `FACTORY_CANDIDATE_PULL_REQUEST_TYPES` for the pull-request trigger matrix and `factoryCandidateOrPushCondition()` on each substantive job. With an optional caller-owned path condition, the helper emits the GitHub expression that runs merge-target pushes unconditionally and runs pull-request work only when GitHub's draft boolean says the pull request is a Candidate:
66
+ `factoryWorkflow()` still does not set `runsOn`. This package does not depend on gagen. Job ids, permissions, topology, and deploy policy stay with the caller. Verify runner kinds are this package's business. Name each job's kind (or an override with a non-empty why), pass `.label` into gagen `job()`, and hand the same table to `assertVerifyRunnerPolicy` next to `assertWorkflowShellParses`:
67
67
 
68
68
  ```ts
69
+ import {
70
+ assertVerifyRunnerPolicy,
71
+ assertWorkflowShellParses,
72
+ factoryVerifyRunner,
73
+ type FactoryVerifyRunner,
74
+ } from "@patronage/factory-ci";
75
+
76
+ type VerifyJobId = "changes" | "core";
77
+
78
+ const verifyRunners: Record<VerifyJobId, FactoryVerifyRunner> = {
79
+ changes: factoryVerifyRunner("rounding-tax"),
80
+ core: factoryVerifyRunner("sustained-compute"),
81
+ };
82
+
83
+ const runsOnFor = (id: VerifyJobId) => verifyRunners[id].label;
84
+
69
85
  const changes = job("changes", {
70
86
  if: factoryCandidateOrPushCondition(),
71
- // caller-owned runner, permissions, and steps
87
+ runsOn: runsOnFor("changes"),
72
88
  });
73
89
 
74
90
  const core = job("core", {
75
91
  if: factoryCandidateOrPushCondition("needs.changes.outputs.core == 'true'"),
76
- // caller-owned topology
92
+ runsOn: runsOnFor("core"),
93
+ });
94
+
95
+ const generated = workflow({ jobs: [changes, core] });
96
+
97
+ assertWorkflowShellParses(generated.toYamlString(), {
98
+ source: ".github/workflows/verify.ts",
99
+ });
100
+ assertVerifyRunnerPolicy(generated.toYamlString(), {
101
+ source: ".github/workflows/verify.ts",
102
+ jobs: verifyRunners,
77
103
  });
78
104
  ```
79
105
 
106
+ `sustained-compute` and `rounding-tax` both resolve to `depot-ubuntu-24.04`. They stay distinct so a short billed-rounding job cannot be confused with a long compute job in the table. `app-token` resolves to `ubuntu-latest`. `deploy-credentials` also resolves to `ubuntu-latest` and is refused inside `assertVerifyRunnerPolicy`, because a Verify workflow has no Cloudflare production secret. HQ deploy keeps a literal `ubuntu-latest` and never calls this assert.
107
+
108
+ Candidate lifecycle support follows the same boundary. Use `FACTORY_CANDIDATE_PULL_REQUEST_TYPES` for the pull-request trigger matrix and `factoryCandidateOrPushCondition()` on each substantive job. With an optional caller-owned path condition, the helper emits the GitHub expression that runs merge-target pushes unconditionally and runs pull-request work only when GitHub's draft boolean says the pull request is a Candidate. The runner label still comes from the kind table above.
109
+
80
110
  Migration: upgrade `@patronage/factory-ci`, apply the shared trigger types and job condition in the TypeScript workflow source, then regenerate and commit the emitted YAML. Draft `opened` and `synchronize` events will stop running substantive Verify work. Promotion through `ready_for_review`, later non-draft Candidate events, and merge-target pushes continue to run their applicable battery. A skipped draft run is presentation, not proof.
81
111
 
82
112
  Production impact support follows the same ownership line. A generated deploy workflow declares only its target names and consumes the returned decision step and fail-open `demandedIf(target, decisionJob)` expressions:
@@ -257,7 +287,10 @@ The audit job is `always()` so a failed destroy still runs the stage-wide check.
257
287
  ### Merge-freeze writer job
258
288
 
259
289
  ```ts
260
- import { factoryMergeFreezeJob } from "@patronage/factory-ci";
290
+ import {
291
+ factoryMergeFreezeJob,
292
+ factoryVerifyRunner,
293
+ } from "@patronage/factory-ci";
261
294
 
262
295
  const freeze = factoryMergeFreezeJob({
263
296
  createGithubAppToken: workflowArtifact.actions.createGithubAppToken,
@@ -272,7 +305,7 @@ const jobs = [
272
305
  name: freeze.jobName,
273
306
  needs: freeze.needs,
274
307
  if: freeze.if,
275
- runsOn: "ubuntu-latest",
308
+ runsOn: factoryVerifyRunner("app-token").label,
276
309
  permissions: freeze.permissions,
277
310
  timeoutMinutes: 10,
278
311
  steps: freeze.steps,
@@ -282,7 +315,7 @@ const jobs = [
282
315
 
283
316
  A consumer's generated merge-target-push Verify workflow is the only producer of `patronage-factory/merge-freeze` generations (#356, ADR 0016 as amended; #429, #872). A red merge-target Verify completes a generation active, a green one completes it inactive, and `pr:ready` reads the generation on the candidate's own base tip. No command, scheduled job, or second workflow writes that check run; the operator override (`demand:waive --demand merge-freeze`) waives the demand for one candidate and never writes here.
284
317
 
285
- `factoryMergeFreezeJob` owns everything a reader trusts: the check name (`FACTORY_MERGE_FREEZE_CHECK_NAME`), the pinned Patronage Factory App identity, the `actions/create-github-app-token` inputs, the `needs.*.result` fold (`FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION`), the merge-target condition (`FACTORY_MERGE_FREEZE_IF`, built from `FACTORY_MERGE_TARGET_REF_CONDITION`), and the job's least-privilege `permissions` (an empty block: the App installation token carries the check-run write, the job uses no `GITHUB_TOKEN` scope, and it never checks out the repository). The caller owns the runner, the timeout, and the `needs` list.
318
+ `factoryMergeFreezeJob` owns everything a reader trusts: the check name (`FACTORY_MERGE_FREEZE_CHECK_NAME`), the pinned Patronage Factory App identity, the `actions/create-github-app-token` inputs, the `needs.*.result` fold (`FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION`), the merge-target condition (`FACTORY_MERGE_FREEZE_IF`, built from `FACTORY_MERGE_TARGET_REF_CONDITION`), and the job's least-privilege `permissions` (an empty block: the App installation token carries the check-run write, the job uses no `GITHUB_TOKEN` scope, and it never checks out the repository). The caller owns the runner kind (`app-token` for the Factory App token), the timeout, and the `needs` list.
286
319
 
287
320
  That `needs` list is the whole trust input: it must name **every authoritative verification leaf**, because the fold is what decides active or inactive. An empty list is refused — a fold over no needed job always reports success, which would report a merge target nothing verified as green.
288
321
 
@@ -479,10 +512,183 @@ The **environment capture is the reason this is shared**. The #640 runner compar
479
512
 
480
513
  **Everything a repository decides stays with the repository**: worker counts, sample counts, slow-list length, output-path conventions, runner labels, artifact upload, and console output. `onSampleStart` / `onSampleComplete` hand the caller each sample so it can print whatever it prints; this package logs nothing. `stdio` for the Vitest child is the caller's too — `"inherit"` by default, so a caller whose own stdout is structured passes `"ignore"`. A reporting hook is a console, not a control: if `onSampleStart` or `onSampleComplete` throws, the sample still runs and the `VitestProfileError` still wins, and the hook's error surfaces only when the sample was otherwise green. `samples` below one is refused outright — it would resolve green having measured nothing and written no artifact. `now`, `runSample`, and `writeResult` are injectable seams for tests.
481
514
 
515
+ ### Alchemy subpath
516
+
517
+ ```ts
518
+ import { ALCHEMY_SUBPATH, evaluateStack } from "@patronage/factory-ci/alchemy";
519
+ ```
520
+
521
+ `@patronage/factory-ci/alchemy` is the one place in this package that may import `alchemy` or `effect` (ADR 0031). Its source is `src/alchemy/`, it builds as its own entry, and it is the only subpath — there is no per-module subpath.
522
+
523
+ Install `alchemy` and `effect` yourself before importing it. The published ranges are `>=2.0.0-beta.63 <3` and `>=4.0.0-beta.98 <5`, the same ones `@patronage/alchemy-d1-state` carries, and both are marked optional so an installation that never touches this surface stays quiet.
524
+
525
+ The published ranges are wide, but the workspace pins one copy: `alchemy` and `effect` are devDependencies here at the same versions `@patronage/alchemy-d1-state` and `software-factory-hq` pin. Without that pin pnpm resolves the peer range to its newest release for this importer alone, which is a second copy of both in the workspace and a lockfile that no longer installs. `evaluateStack` imports both, so `knip` sees them used.
526
+
527
+ `ALCHEMY_SUBPATH` is the specifier itself. Stage policy, retained-identity asserts, the lifecycle guards, stack evaluation, and the graph invariants ship here. The Alchemy entry helpers named above (`bundleAlchemyEntry`, `executeAlchemyEntry`) stay on the package root, where they belong: they shell out to the consumer's Alchemy CLI and import neither package.
528
+
529
+ #### Stage policy
530
+
531
+ ```ts
532
+ import {
533
+ anyStage,
534
+ LOCAL_PREVIEW_STAGES,
535
+ stagePolicy,
536
+ } from "@patronage/factory-ci/alchemy";
537
+
538
+ const policy = stagePolicy({
539
+ stacks: [
540
+ { claims: ["bootstrap"], stack: "bootstrap-stack", stages: ["bootstrap"] },
541
+ { claims: ["telemetry"], stack: "app" },
542
+ { stack: "audit", stages: ["staging", "prod"] },
543
+ ],
544
+ stages: {
545
+ disposable: anyStage(/^pr-\d+$/u, LOCAL_PREVIEW_STAGES),
546
+ protected: ["bootstrap", "dev", "prod", "staging", "telemetry"],
547
+ },
548
+ });
549
+
550
+ policy.assertDestructiveStage(stage); // throws on a protected stage
551
+ policy.assertStackOwnsStage(stack, stage);
552
+ ```
553
+
554
+ Two projects wrote the same three questions — is this stage protected, is it disposable, and may this stack run it — and each answered them with its own stage names compiled into the answer. `stagePolicy` takes the table and returns the decisions: `isProtectedStage`, `isDisposableStage`, `isKnownStage`, `assertKnownStage`, `assertDestructiveStage`, and `assertStackOwnsStage`. **No project, stack, or stage name appears in this package.**
555
+
556
+ A `StageMatcher` is a name list, a `RegExp`, or a predicate — the predicate is how a project expresses a stage set it resolves itself, such as one config file per client. `anyStage(...)` composes matchers, and `LOCAL_PREVIEW_STAGES` is the disposable grammar this package already owns (`isLocalPreviewStage`), offered as a matcher rather than assumed, because a project's disposable set is wider than that one grammar.
557
+
558
+ Stack ownership runs in two independent directions. `stages` restricts a stack to the stages it may run. `claims` reserves stages for one stack. Declaring the same matcher as both pairs a stack and its stages in both directions; declaring `claims` alone reserves a stage without narrowing the stack that owns it. A declared `stacks` registry is exhaustive, so a mistyped stack is refused rather than passing as unconstrained; omit `stacks` to leave ownership unpoliced.
559
+
560
+ Refusals are `StagePolicyError` with a stable `code` — `UNKNOWN_STAGE`, `AMBIGUOUS_STAGE`, `PROTECTED_STAGE`, `UNKNOWN_STACK`, `STAGE_NOT_OWNED` — so a project maps them onto its own lifecycle failure envelope instead of matching on message text. A stage a table classifies as both protected and disposable is a table defect: every query on it throws `AMBIGUOUS_STAGE` rather than silently picking a side.
561
+
562
+ What stays with the project: which environment variable authorizes an attended protected operation, what counts as a trusted hosted context, whether an outstanding state-transfer proof blocks a stack, and the shape of its own failure envelope. Those name credentials, issues, and contexts, and none of them is a stage question.
563
+
564
+ #### Retained identities
565
+
566
+ ```ts
567
+ import {
568
+ assertRetainedIdentities,
569
+ parseStateSnapshot,
570
+ } from "@patronage/factory-ci/alchemy";
571
+
572
+ const scope = { stack: "loop", stage: "prod" };
573
+
574
+ assertRetainedIdentities({
575
+ after: parseStateSnapshot(currentState, scope),
576
+ before: parseStateSnapshot(recordedState, scope),
577
+ markers: [
578
+ {
579
+ field: "databaseId",
580
+ fqn: "app-db",
581
+ resourceType: "Cloudflare.D1Database",
582
+ section: "attr",
583
+ value: expectedDatabaseId,
584
+ },
585
+ ],
586
+ });
587
+ ```
588
+
589
+ A protected stage's value is the physical resources it already owns. An Alchemy deploy that no longer recognises one of them does not fail — it creates a second one and leaves the first orphaned with the data still in it. `assertRetainedIdentities` reads the recorded state before the operation runs and throws `RetainedIdentityError` when an identity is `missing`, `unexpected` (not the `value` the marker names), or `changed` between the two snapshots. Every violation is reported, not just the first.
590
+
591
+ `parseStateSnapshot` reads `alchemy state export` output — the bulk document, `{ resources: [{ stack, stage, fqn, state }] }`, not the single record `alchemy state get` prints. Run without `--stack` the command exports the whole estate, so one fqn appears once per stage; pass the second argument, `{ stack, stage }`, to narrow the document to the stage the markers are about. A scoped document that still records one fqn twice throws rather than letting a marker match whichever record sorted first. State it cannot read throws, because reading a broken state as an empty one turns a failed read into a passing assert. An evaluated stack graph is a different artifact carrying the same identities; a caller holding one projects it onto `StateSnapshot` at the call site.
592
+
593
+ Two inputs are refused outright rather than passing: an empty marker list, and a marker with neither a `value` nor a `before` snapshot to compare against. Both would resolve green having asserted nothing.
594
+
595
+ #### Lifecycle guards
596
+
597
+ ```ts
598
+ import {
599
+ assertDestroyLeftNothing,
600
+ credentialPreflight,
601
+ firstDeployAuthorization,
602
+ secretNamePolicy,
603
+ } from "@patronage/factory-ci/alchemy";
604
+ ```
605
+
606
+ 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.
607
+
608
+ `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.
609
+
610
+ `firstDeployAuthorization({ authorizedHashes, planText, sourceSha, stack, stage })` binds the one deploy no earlier state can constrain. The plan text, 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, 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.
611
+
612
+ `assertDestroyLeftNothing({ stack, stage, stateReader })` is the postcondition that a zero exit does not prove: after `destroy`, the state store lists nothing for the stage. The project supplies `stateReader`, because only the project knows which store it configured. A reader that returns anything other than a list of non-blank resource identifiers is a failure, not an empty stage — a check that cannot fail is not a check. A padded stack or stage is refused for the same reason: it addresses a stage the store does not know, which answers empty.
613
+
614
+ `secretNamePolicy({ names, values })` builds the `redact` function `executeAlchemyEntry` already takes. It is a policy, not a mechanism. The project passes the names; this package ships no secret-name list and infers none from a name pattern. The policy is fail-closed at construction: a declared name with no value, a value under eight characters, or an empty `names` list handed a populated `values` map, throws before there is any output to redact, rather than quietly covering less than the caller believes. Empty names beside empty values is the one pass-through: a project with no secrets in its Alchemy output declares none. Values are trimmed before replacement, so redaction never swallows the line break after a secret, and longer values are replaced first, so a secret containing another secret leaves no fragment.
615
+
616
+ Four things paitronage does are deliberately not here. The plan-read-only before/after state capture is dropped: `Plan.make` never mutates, so the assertion re-proved a guarantee. Stage eligibility is dropped: which stages may take a first deploy is project policy, and `firstDeployAuthorization` answers only whether this exact plan was authorized. `assertFirstDeployEffects` — the check that a first-deploy plan may only create, never update or delete — stays with the consumer: it reads the plan's own effect shape, which is Alchemy's type and the project's grammar, not a hash. The live zone lookup on the hosted path stays with the consumer for the same reason: which zones an account may deploy into is project policy, and this package makes one Cloudflare call, against the token, not against the account's inventory.
617
+
618
+ #### `evaluateStack`
619
+
620
+ ```ts
621
+ import { evaluateStack } from "@patronage/factory-ci/alchemy";
622
+
623
+ const graph = await evaluateStack({
624
+ name: "hq",
625
+ program,
626
+ stage: "placeholder",
627
+ });
628
+ // { resources: [{ id, type, props, upstreamByProp }], bindings: [{ worker, name, type, target? }] }
629
+ ```
630
+
631
+ `evaluateStack` runs a stack program at one stage and returns the graph Alchemy would plan, with no state store and no network. It wraps Alchemy's `Plan.make`: the program registers its resources under a `Stack` service, then the plan builds under `inMemoryState()` at the requested `Stage`. Every provider answers its `read` and `list` probes with "absent", so the cold-adoption probe `Plan.make` makes for a resource with resolved props and no prior state never leaves the process. A stage the program returns from early (paitronage's `placeholder` stage) evaluates to an empty graph.
632
+
633
+ `program` is the effect the consumer hands `Alchemy.Stack`, not the `Alchemy.Stack(...)` value. The value captures the consumer's real `providers` and `state` layers, and at Alchemy 2.0.0-beta.76 `Cloudflare.providers()` resolves credentials when its layer builds (`Credentials.fromAuthProvider` inside `CloudflareApiLive`), so evaluating the value cannot stay credential-free. Export the program next to the stack:
634
+
635
+ ```ts
636
+ export const program = Effect.gen(function* () {
637
+ /* ... */
638
+ });
639
+ export default Alchemy.Stack("hq", { providers, state }, program);
640
+ ```
641
+
642
+ Pass `providers` when the program depends on hand-written provider layers. Every provider that layer registers keeps its identity (`stables`, `aliases`, `diff`) and loses its probes; an unregistered resource type still evaluates. Each graph resource carries the `LogicalId`, `Type`, and raw `Props` of its plan node, so a prop may hold an unresolved Alchemy output. `upstreamByProp` names, per top-level prop, the logical ids that prop's value references, which is the only way to read a cross-resource reference out of raw props: an output is a function, so the prop that holds it says nothing on its own. A prop that references nothing carries no key. The references are kept per prop and never pooled into one list, so a caller asking which resources one prop names is never answered with an id a different prop mentioned. Each binding row comes from Alchemy's Worker binding channel: `worker` is the host's logical id, `name` and `type` are the strings Alchemy emits (`d1`, `kv_namespace`, `secrets_store_secret`, and so on), and `target` is the logical id of the resource the row references, when it references one.
643
+
644
+ The caller's `providers` layer is **built** before its probes are stubbed: `evaluateStack` wraps that layer, and Alchemy's own lookup resolves each provider out of it before `read` and `list` are replaced. So any side effect a layer performs at construction — a credential read, a network call, a file write — is the caller's, and happens. This is why `Cloudflare.providers()` must not be passed: at beta.76 it resolves credentials when its layer builds (`Credentials.fromAuthProvider` inside `CloudflareApiLive`), before there is anything to stub. The no-network guarantee covers evaluation, not layer construction: pass only layers whose construction is inert.
645
+
646
+ The helper decides nothing about the graph. Invariants over it are separate exports; `assertUrlImpliesAuth` below is the first.
647
+
648
+ #### `assertUrlImpliesAuth`
649
+
650
+ ```ts
651
+ import { assertUrlImpliesAuth } from "@patronage/factory-ci/alchemy";
652
+
653
+ assertUrlImpliesAuth(graph, {
654
+ allowlist: [
655
+ {
656
+ reason: "Public marketing site, no data behind it.",
657
+ worker: "marketing",
658
+ },
659
+ ],
660
+ authBindingName: /_TOKEN$/u,
661
+ });
662
+ ```
663
+
664
+ Every publicly reachable Worker in the graph must declare how it is authenticated. The cheapest way to publish an unauthenticated admin surface is to add `workersDev: true`, a `domain`, or a route to a Worker that never had one: nothing fails, and the only record of the decision is a diff nobody re-read.
665
+
666
+ A Worker is publicly reachable when the stack **declares** it so — `workersDev` on (the boolean, or a config object that does not turn both the stable URL and version previews off), a `domain`, or a non-empty `routes` list. A route is a hostname pattern on a zone the account owns, so it is as public as a domain is. Alchemy's own default for `workersDev` is on, so a Worker that declares none of the three still gets a `workers.dev` URL, and this check leaves it alone: a rule inferred from a default fires on every Worker in every stack and is waived everywhere within a week. A project that wants the default stated declares `workersDev: false` on the Workers that are private.
667
+
668
+ A reachable Worker passes on any one of four guards. It enrolls in Cloudflare Access through its own `access` prop. A `Cloudflare.Access.Application` resource declares an `all_workers` destination, or a `worker` destination whose own `upstreamByProp.destinations` names it — an application that also declares a `preview_worker` destination credits nobody, because the graph resolves a destination's worker reference to a logical id but not to the destination holding it, and a preview-only reference protects preview URLs rather than the production one. It carries a `secrets_store_secret` binding whose name `authBindingName` accepts, as a `RegExp` or a predicate. Or `allowlist` names it with a reason; an empty reason fails louder than the bare Worker, because the entry claims a decision nobody made.
669
+
670
+ What counts as a guard is the project's call: the binding-name matcher and the allowlist are both caller-supplied, and this package ships no worker names and no name pattern. Every offending Worker is named in one `UrlImpliesAuthError`, whose `violations` carry the worker and whether it was unauthenticated or allowlisted without a reason.
671
+
672
+ The factory's own security review prompt carries the same rule in prose, so a repository with no graph check still gets it read by a reviewer.
673
+
674
+ ### Alchemy fleet baseline
675
+
676
+ ```ts
677
+ import { ALCHEMY_BASELINE, assertAlchemyBaseline } from "@patronage/factory-ci";
678
+ ```
679
+
680
+ `ALCHEMY_BASELINE` is the exact `alchemy` and `effect` pair the fleet moves together on: `{ alchemy: "2.0.0-beta.76", effect: "4.0.0-rc.112" }`. It is a plain constant on the root entry — it imports neither package — so any consumer can read it without installing the `./alchemy` subpath's peers.
681
+
682
+ `assertAlchemyBaseline({ packageJson })` is a consumer contract helper: it fails when the consumer's own `dependencies` or `devDependencies` pin `alchemy` or `effect` to anything other than the baseline. A package the consumer does not depend on at all is not drift — both are optional peers of the subpath, so a consumer that never imports it, such as this package's own CLI, carries neither and passes. The pin is exact, not a range: the fleet is pre-1.0 and moves together, so a range would let one project drift silently ahead of or behind the rest. `assertAlchemyBaseline` reads only the object it is handed; it never walks the filesystem or reads a lockfile. Call it from a consumer's own contract test, passing that consumer's parsed `package.json`.
683
+
684
+ The baseline bumps only in a lockstep release: the same commit that bumps `@patronage/factory-ci`'s own `alchemy`/`effect` devDependencies and the `./alchemy` subpath's peer ranges (ADR 0031) also bumps `ALCHEMY_BASELINE`, so the constant a consumer's contract test checks against can never point at a version the subpath itself has moved past.
685
+
482
686
  ### Published-package contract
483
687
 
484
688
  The tests pack the actual tarball, extract it into a throwaway external consumer, import the built package root without the workspace's `development` condition, assert the exact runtime exports, and exercise the workflow and stage interfaces. This catches source-only successes, stale or missing `dist/`, exports-map mistakes, and accidental tarball growth before the attended release check.
485
689
 
690
+ The same consumer proves the ADR 0031 rule from the packed bytes rather than from source: it imports `@patronage/factory-ci/alchemy` with neither `alchemy` nor `effect` installed, and it bundles the packed `dist/index.js` so esbuild's own resolution reports every bare specifier the root graph still needs. Either package appearing in that list fails the build.
691
+
486
692
  ## No configuration surface
487
693
 
488
694
  Functions take plain typed objects. Proof and preview configuration lives in the canonical `software-factory.profile.json` schema, never in this package (ADR 0021); consumers pass the relevant profile policy directly at generation time.
@@ -0,0 +1,3 @@
1
+ {
2
+ "fingerprint": "c63c213989e286a5baa59a9e2e2df1db05b55a0bc94208ecde13fb5a2384e5f0"
3
+ }