@pithy-sh/core 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/naming/domains.d.ts +43 -3
  2. package/dist/naming/domains.d.ts.map +1 -1
  3. package/dist/naming/domains.js +62 -4
  4. package/dist/naming/feature.d.ts +126 -11
  5. package/dist/naming/feature.d.ts.map +1 -1
  6. package/dist/naming/feature.js +184 -30
  7. package/dist/naming/limits.d.ts +23 -7
  8. package/dist/naming/limits.d.ts.map +1 -1
  9. package/dist/naming/limits.js +22 -9
  10. package/dist/naming/provisionScope.d.ts +25 -11
  11. package/dist/naming/provisionScope.d.ts.map +1 -1
  12. package/dist/naming/provisionScope.js +28 -11
  13. package/dist/seed/seed.d.ts +36 -7
  14. package/dist/seed/seed.d.ts.map +1 -1
  15. package/dist/version.generated.d.ts +1 -1
  16. package/dist/version.generated.js +1 -1
  17. package/dist/worker/identity.d.ts +9 -0
  18. package/dist/worker/identity.d.ts.map +1 -1
  19. package/dist/worker/identity.js +10 -1
  20. package/dist/workflow/faults.d.ts +34 -2
  21. package/dist/workflow/faults.d.ts.map +1 -1
  22. package/dist/workflow/faults.js +42 -18
  23. package/dist/workflow/host.d.ts +7 -0
  24. package/dist/workflow/host.d.ts.map +1 -1
  25. package/dist/workflow/host.js +5 -3
  26. package/dist/workflow/naming.d.ts +8 -0
  27. package/dist/workflow/naming.d.ts.map +1 -1
  28. package/dist/workflow/naming.js +15 -0
  29. package/package.json +1 -1
  30. package/src/naming/domains.ts +75 -5
  31. package/src/naming/feature.ts +274 -38
  32. package/src/naming/limits.ts +25 -8
  33. package/src/naming/provisionScope.ts +39 -14
  34. package/src/seed/seed.ts +36 -7
  35. package/src/version.generated.ts +1 -1
  36. package/src/worker/identity.ts +10 -0
  37. package/src/workflow/faults.ts +61 -4
  38. package/src/workflow/host.ts +15 -3
  39. package/src/workflow/naming.ts +25 -0
@@ -1,9 +1,9 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { ValidationError } from "../error/pithyError.js";
4
- import { featureMarker } from "./environment.js";
4
+ import { featureMarker, isFeatureMarker } from "./environment.js";
5
5
  import { NAMESPACE_LIMITS } from "./limits.js";
6
- import { assertValidProjectName, fitSegment, kebab } from "./resource.js";
6
+ import { assertValidProjectName, kebab } from "./resource.js";
7
7
  //#region src/naming/feature.ts
8
8
  /** Every feature resource kind, for tests and for callers that enumerate. */
9
9
  const FEATURE_RESOURCE_KINDS = [
@@ -11,8 +11,6 @@ const FEATURE_RESOURCE_KINDS = [
11
11
  "kv",
12
12
  "r2"
13
13
  ];
14
- /** Below this the slug segment isn't worth keeping legible; the trailing segment is truncated to give it room. */
15
- const MIN_SLUG_BUDGET = 3;
16
14
  /**
17
15
  * Refuse an issue number the budgets were not derived against.
18
16
  *
@@ -29,21 +27,106 @@ function assertIssue(issue) {
29
27
  detail: `Feature resource names reserve 6 digits, and every project-name budget is derived against that.`
30
28
  });
31
29
  }
30
+ /**
31
+ * **The issue as a name carries it: its digits, without leading zeros (#643).** `feature/0643-foo` and
32
+ * `feature/643-foo` are one issue, so they are one feature and compose one set of names. Read as strings they
33
+ * were two features, and their rate-limit namespaces collided every time. `0` stays `0`.
34
+ */
35
+ function canonicalIssue(issue) {
36
+ assertIssue(issue);
37
+ return issue.replace(/^0+(?=[0-9])/, "");
38
+ }
39
+ /**
40
+ * The `f<digits>` segment a project name carries, or `null` — the shape a feature's issue takes (#643).
41
+ *
42
+ * Read after kebabbing, because the kebab is what reaches an account.
43
+ */
44
+ function featureMarkerInProjectName(project) {
45
+ return kebab(project).split("-").find(isFeatureMarker) ?? null;
46
+ }
47
+ /**
48
+ * **Refuse features for a project whose name carries an `f<digits>` segment (#643).**
49
+ *
50
+ * A feature name is read left to right: the project, then the first `f<digits>` segment, which is the issue.
51
+ * A project named `acme-f12-x` puts that segment inside itself, and then its feature 3 on `y` and project `acme`'s
52
+ * feature 12 on `x-f3-y` are one string. Environment names already obey this rule ({@link isFeatureMarker}), so
53
+ * the project obeys it too, where the two shapes meet.
54
+ *
55
+ * **Refused here, not at `requireProjectName`.** Such a project's staging and prod are untouched: no name they
56
+ * compose holds {@link FEATURE_TAIL_SEPARATOR}, so no feature anywhere can compose one of theirs. Refusing the
57
+ * name everywhere would break every command of a project that is otherwise sound. `pithy init` refuses a new one,
58
+ * and `pithy doctor` names the segment an existing one carries. Nothing is renamed.
59
+ */
60
+ function assertFeatureProject(project) {
61
+ const marker = featureMarkerInProjectName(project);
62
+ if (marker === null) return;
63
+ throw new ValidationError({
64
+ message: `"${project}" can't have feature environments. Its name carries ${marker}, the shape a feature's issue takes.`,
65
+ action: "Staging and prod are unaffected. Features need a project name with no f and a number as one segment.",
66
+ detail: `A feature name is <project>-f<issue>-<slug>--<thing>, read from the first f<digits> segment. With one inside "${kebab(project)}", another project's feature could compose this project's feature names.`
67
+ });
68
+ }
32
69
  /** The `<project>-f<issue>` head both feature shapes share, with the project held to the one project rule. */
33
70
  function head(identity) {
34
71
  assertValidProjectName(identity.project);
35
- assertIssue(identity.issue);
36
- return `${kebab(identity.project)}-${featureMarker(identity.issue)}`;
72
+ assertFeatureProject(identity.project);
73
+ return `${kebab(identity.project)}-${featureMarker(canonicalIssue(identity.issue))}`;
74
+ }
75
+ /**
76
+ * **Read a feature name back into its owner, or `null` when it is not one (#643).**
77
+ *
78
+ * Exact, because the shape leaves one reading: a feature name holds {@link FEATURE_TAIL_SEPARATOR} exactly once,
79
+ * everything before it is `<project>-f<issue>-<slug>`, and the first `f<digits>` segment there is the issue,
80
+ * since a project may carry none. Nothing else Pithy composes holds the separator at all.
81
+ */
82
+ function parseFeatureName(name) {
83
+ const at = name.indexOf("--");
84
+ if (at < 0 || name.indexOf("--", at + 1) >= 0) return null;
85
+ const thing = name.slice(at + 2);
86
+ const segments = name.slice(0, at).split("-");
87
+ const marker = segments.findIndex(isFeatureMarker);
88
+ if (thing === "" || marker < 1 || marker === segments.length - 1) return null;
89
+ if (segments.some((segment) => segment === "")) return null;
90
+ return {
91
+ project: segments.slice(0, marker).join("-"),
92
+ issue: segments[marker].slice(1).replace(/^0+(?=[0-9])/, ""),
93
+ slug: segments.slice(marker + 1).join("-"),
94
+ thing
95
+ };
96
+ }
97
+ /**
98
+ * **Is this name one this feature composes?** The ownership check the isolation gates ask (#643).
99
+ *
100
+ * Parsed, never prefix-matched, and exact: the name's project, issue and slug are this feature's, character for
101
+ * character. `<project>-f<issue>-` is shared by every branch of one issue, so a prefix match reached siblings; and
102
+ * a fitted form of the slug, which this used to accept, is a short hash some sibling can have as its whole slug
103
+ * (`feature/12-login` owned `feature/12-c`). No feature name is fitted any more ({@link composeFeatureName}), so
104
+ * there is nothing but the slug itself to accept.
105
+ */
106
+ function isFeatureOwnedName(identity, name) {
107
+ const parsed = parseFeatureName(name);
108
+ if (parsed === null) return false;
109
+ return parsed.project === kebab(identity.project) && parsed.issue === canonicalIssue(identity.issue) && parsed.slug === kebab(identity.slug);
37
110
  }
38
111
  /**
39
- * Fit `<head>-<slug>-<tail><fixed>` into `budget`, truncating the slug first and the tail only if the
40
- * tail is what is eating the name. `fixed` is never touched — it carries the kind suffix, which is the
41
- * only thing telling a `DB` bucket from a `DB` database.
112
+ * **The longest slug one feature name leaves room for** — `limit` less `<project>-f<issue>-`, the
113
+ * {@link FEATURE_TAIL_SEPARATOR}, and `thing`, which is everything after it. Zero or less when `thing` leaves none.
114
+ */
115
+ function featureSlugRoom(parts, thing, limit) {
116
+ return limit - head(parts).length - 1 - 2 - thing.length;
117
+ }
118
+ /**
119
+ * Compose `<head>-<slug>--<tail><fixed>` whole, or refuse (#643).
42
120
  *
43
- * Deterministic in its inputs and hash-disambiguated on both variable segments, so two long inputs
44
- * sharing a prefix still produce two different names.
121
+ * **Never truncated.** A fitted slug was a short hash, and a hash is a slug some sibling branch can have: the
122
+ * review of 4828e1fc found `feature/12-login` owning `feature/12-c`'s names, and two long slugs of one issue
123
+ * composing one database. A name that does not fit is refused, naming the longest slug that does, and
124
+ * `pithy feature create` and `pithy provision --feature` refuse the branch before anything exists
125
+ * ({@link assertFeatureSlugFits}). This is the backstop behind them: no path composes a feature name that is not
126
+ * exactly its feature's.
45
127
  */
46
- function composeFeatureName(headSegment, slug, tail, fixed, budget) {
128
+ function composeFeatureName(parts, slug, tail, fixed, limit, label) {
129
+ const headSegment = head(parts);
47
130
  for (const [role, value] of [["slug", slug], ["binding or worker", tail]]) {
48
131
  if (value) continue;
49
132
  throw new ValidationError({
@@ -52,29 +135,76 @@ function composeFeatureName(headSegment, slug, tail, fixed, budget) {
52
135
  detail: `composeFeatureName received an empty ${role} segment for "${headSegment}".`
53
136
  });
54
137
  }
55
- const inner = budget - fixed.length;
56
- let tailSegment = tail;
57
- let slugBudget = inner - headSegment.length - 1 - (1 + tailSegment.length);
58
- if (slugBudget < MIN_SLUG_BUDGET) {
59
- const tailBudget = inner - headSegment.length - 1 - MIN_SLUG_BUDGET - 1;
60
- tailSegment = fitSegment(tailSegment, Math.max(1, tailBudget));
61
- slugBudget = inner - headSegment.length - 1 - (1 + tailSegment.length);
138
+ const thing = `${tail}${fixed}`;
139
+ const room = featureSlugRoom(parts, thing, limit);
140
+ if (room < 1) throw new ValidationError({
141
+ message: `"${thing}" is too long for a feature's ${label.replace(/^an? /, "")}.`,
142
+ action: `Shorten it. ${label[0]?.toUpperCase()}${label.slice(1)} stops at ${limit}, and ${headSegment}-<slug>-- comes first.`,
143
+ detail: `${headSegment}-<slug>--${thing} leaves ${room} characters for a slug.`
144
+ });
145
+ if (slug.length > room) throw new ValidationError({
146
+ message: `Slug "${slug}" is ${slug.length} characters. ${label[0]?.toUpperCase()}${label.slice(1)} ${headSegment}-<slug>--${thing} leaves room for at most ${room}.`,
147
+ action: "Use a shorter branch slug.",
148
+ detail: `${headSegment}-${slug}--${thing} is ${headSegment.length + 1 + slug.length + 2 + thing.length} characters; the limit is ${limit}. Feature names are never truncated.`
149
+ });
150
+ return `${headSegment}-${slug}--${thing}`;
151
+ }
152
+ /** The longest slug every one of `shapes` leaves room for, for this project and issue, and the shape that sets it. */
153
+ function tightestFeatureShape(parts, shapes) {
154
+ let max = Number.POSITIVE_INFINITY;
155
+ let shape = null;
156
+ for (const candidate of shapes) {
157
+ const room = featureSlugRoom(parts, candidate.thing, candidate.limit);
158
+ if (room < max) {
159
+ max = room;
160
+ shape = candidate;
161
+ }
62
162
  }
63
- return `${headSegment}-${fitSegment(slug, Math.max(1, slugBudget))}-${tailSegment}${fixed}`;
163
+ return {
164
+ max,
165
+ shape
166
+ };
167
+ }
168
+ /** The longest slug a feature of this project and issue may have, across every name it composes. */
169
+ function maxFeatureSlug(parts, shapes) {
170
+ return tightestFeatureShape(parts, shapes).max;
171
+ }
172
+ /**
173
+ * **Refuse a branch whose slug cannot fit every name its feature composes (#643).** The one refusal
174
+ * `pithy feature create` and `pithy provision --feature` make, before a worktree or a resource exists, with the
175
+ * project's maximum in the message. {@link composeFeatureName} refuses the same slug one name at a time; this says
176
+ * it once, for all of them.
177
+ */
178
+ function assertFeatureSlugFits(identity, shapes) {
179
+ const { max, shape } = tightestFeatureShape(identity, shapes);
180
+ const slug = kebab(identity.slug);
181
+ if (shape === null || slug.length <= max) return;
182
+ const issue = canonicalIssue(identity.issue);
183
+ const project = kebab(identity.project);
184
+ if (max < 1) throw new ValidationError({
185
+ message: `"${shape.thing}" leaves no room for a slug in ${project}'s features.`,
186
+ action: `Shorten it. ${shape.label[0]?.toUpperCase()}${shape.label.slice(1)} stops at ${shape.limit}.`,
187
+ detail: `${head(identity)}-<slug>--${shape.thing} leaves ${max} characters for a slug.`
188
+ });
189
+ throw new ValidationError({
190
+ message: `Slug "${slug}" is ${slug.length} characters. Feature slugs in ${project} stop at ${max} characters at issue ${issue}.`,
191
+ action: `Name the branch feature/${issue}-<slug of ${max} or fewer>. Feature names are never truncated.`,
192
+ detail: `${shape.label} ${head(identity)}-<slug>--${shape.thing} is held to ${shape.limit} characters.`
193
+ });
64
194
  }
65
195
  /**
66
- * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>-<binding>-<kind>`.
196
+ * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>--<binding>-<kind>`.
67
197
  *
68
198
  * Held to **R2's 63**, the strictest of the three kinds a feature provisions, so one shape is legal for
69
199
  * all of them: lowercase, hyphenated, alphanumeric at both ends. A D1 or KV name could be longer, but a
70
200
  * feature that provisions a bucket and a database wants them recognizably the same name.
71
201
  */
72
202
  function featureResourceName(identity, binding, kind) {
73
- return composeFeatureName(head(identity), kebab(identity.slug), kebab(binding), `-${kind}`, NAMESPACE_LIMITS.r2.maxLength);
203
+ return composeFeatureName(identity, kebab(identity.slug), kebab(binding), `-${kind}`, NAMESPACE_LIMITS.r2.maxLength, NAMESPACE_LIMITS.r2.label);
74
204
  }
75
205
  /**
76
206
  * The CF Secrets Store entry name holding one of a feature's **environment-scoped** secrets —
77
- * `<project>-f<issue>-<slug>-<secret>`.
207
+ * `<project>-f<issue>-<slug>--<secret>`.
78
208
  *
79
209
  * A Cloudflare account has one Secrets Store, flat and unpartitionable, so the entry name is the only
80
210
  * partition there is. A feature therefore needs its own names for the same reason it needs its own
@@ -84,14 +214,14 @@ function featureResourceName(identity, binding, kind) {
84
214
  * no documented Cloudflare cap, and holding it to the strictest kind's would hash
85
215
  * `secrets-encryption-keys` down to nothing for no reason.
86
216
  *
87
- * A `global` secret is not named here at all: it is one account-level value every environment binds,
88
- * so a feature binds the project's `<project>-global-<secret>` rather than minting a second copy.
217
+ * A `global` secret is named here too since #643: a feature shares nothing with any other environment, so
218
+ * its entry for a global secret is its own rather than the project's `<project>-global-<secret>`.
89
219
  */
90
220
  function featureSecretEntryName(identity, secret) {
91
- return composeFeatureName(head(identity), kebab(identity.slug), kebab(secret), "", NAMESPACE_LIMITS.secretEntry.maxLength);
221
+ return composeFeatureName(identity, kebab(identity.slug), kebab(secret), "", NAMESPACE_LIMITS.secretEntry.maxLength, NAMESPACE_LIMITS.secretEntry.label);
92
222
  }
93
223
  /**
94
- * The Worker **script name** for one of a feature's workers — `<project>-f<issue>-<slug>-<worker>`.
224
+ * The Worker **script name** for one of a feature's workers — `<project>-f<issue>-<slug>--<worker>`.
95
225
  *
96
226
  * This is the name the feature's Workers deploy under, and therefore the name a sibling's `service`
97
227
  * binding must target, so RPC inside a feature environment reaches that feature's deployment rather than
@@ -103,13 +233,37 @@ function featureSecretEntryName(identity, secret) {
103
233
  * has a charset rule and no length rule, so nothing upstream was going to stop it either.
104
234
  *
105
235
  * **`app` is the `apps/<app>` directory, never the deploy name.** A scaffolded Worker deploys as
106
- * `<project>-<app>`, and handing that here composed `<project>-f<issue>-<slug>-<project>-<app>` — the
236
+ * `<project>-<app>`, and handing that here composed `<project>-f<issue>-<slug>--<project>-<app>` — the
107
237
  * project twice, spent out of the budget above (#587). This function takes a string and cannot tell the
108
238
  * two apart, so the provisioning path reaches it only through `featureScope`, which is handed both names
109
239
  * and picks.
110
240
  */
111
241
  function featureWorkerName(identity, app) {
112
- return composeFeatureName(head(identity), kebab(identity.slug), kebab(app), "", NAMESPACE_LIMITS.worker.maxLength);
242
+ return composeFeatureName(identity, kebab(identity.slug), kebab(app), "", NAMESPACE_LIMITS.worker.maxLength, NAMESPACE_LIMITS.worker.label);
243
+ }
244
+ /**
245
+ * The deployed name of one of a feature's **Workflows** — `<project>-f<issue>-<slug>--<capability>-<job>` (#643).
246
+ *
247
+ * A Workflow name is account-wide, like a Worker's, so a feature's email host cannot run
248
+ * `<project>-feature-email-send`: every open branch would deploy the same Workflow over every other's. It takes
249
+ * the feature's head instead, exactly as its host Worker does ({@link featureWorkerName}), so the Worker and the
250
+ * Workflows it hosts are recognizably one feature's and teardown recomputes both from the identity.
251
+ *
252
+ * Held to the **Workflow** limit ({@link NAMESPACE_LIMITS.workflow}), and fitted the way every feature name is:
253
+ * the slug gives way first, then the `<capability>-<job>` tail, deterministically.
254
+ */
255
+ function featureWorkflowName(identity, capability, job) {
256
+ return composeFeatureName(identity, kebab(identity.slug), `${kebab(capability)}-${kebab(job)}`, "", NAMESPACE_LIMITS.workflow.maxLength, NAMESPACE_LIMITS.workflow.label);
257
+ }
258
+ /**
259
+ * The name of one of a feature's **Vectorize indexes** — `<project>-f<issue>-<slug>--<thing>` (#643).
260
+ *
261
+ * An index name is account-wide, so a feature's vector host cannot bind `<project>-feature-vector-<index>`: every
262
+ * open branch would read and write one index. Held to the Vectorize limit and fitted the way every feature name
263
+ * is, so teardown recomputes it from the identity.
264
+ */
265
+ function featureVectorizeIndexName(identity, thing) {
266
+ return composeFeatureName(identity, kebab(identity.slug), kebab(thing), "", NAMESPACE_LIMITS.vectorizeIndex.maxLength, NAMESPACE_LIMITS.vectorizeIndex.label);
113
267
  }
114
268
  //#endregion
115
- export { FEATURE_RESOURCE_KINDS, featureResourceName, featureSecretEntryName, featureWorkerName };
269
+ export { FEATURE_RESOURCE_KINDS, assertFeatureProject, assertFeatureSlugFits, canonicalIssue, featureMarkerInProjectName, featureResourceName, featureSecretEntryName, featureSlugRoom, featureVectorizeIndexName, featureWorkerName, featureWorkflowName, isFeatureOwnedName, maxFeatureSlug, parseFeatureName, tightestFeatureShape };
@@ -28,6 +28,9 @@ export type LimitSource = "cloudflare" | "pithy";
28
28
  * **`truncate`** where Pithy recomputes the name from the same inputs on every command — a D1
29
29
  * database, a KV namespace, a bucket, a secret entry, a token. Truncation is deterministic and
30
30
  * hash-disambiguated, so provision and teardown still agree, and a failed CI run is avoided.
31
+ *
32
+ * **A feature's names are refused in every namespace, whatever its policy here (#643).** A fitted slug is a
33
+ * short hash, and a hash is a slug another branch can have whole; see `composeFeatureName` in `./feature`.
31
34
  */
32
35
  export type OverflowPolicy = "refuse" | "truncate";
33
36
  /** One namespace's rule: how long a name may be, and what happens when it would be longer. */
@@ -180,11 +183,24 @@ export declare const MAX_ISSUE_DIGITS = 6;
180
183
  /**
181
184
  * The slug length a feature resource name keeps verbatim at the worst legal project name.
182
185
  *
183
- * Twelve, because that is where a feature slug stops being readable in a listing: below it,
184
- * `fitSegment` has to fall back to a short head plus a six-hex disambiguator, and
185
- * `acme-f95-medi-8f21c4-db-d1` tells a human nothing about which branch owns the database.
186
+ * Eleven — it was twelve until {@link FEATURE_TAIL_SEPARATOR} took a character of it (#643). About where a
187
+ * feature slug stops being readable in a listing. A slug past a feature's budget is refused, not fitted (#643),
188
+ * so this is the room the project-name cap guarantees every project's features at the worst legal input.
189
+ */
190
+ export declare const MIN_LEGIBLE_SLUG = 11;
191
+ /**
192
+ * **What stands between a feature's slug and whatever it names: two hyphens (#643).**
193
+ *
194
+ * `<project>-f<issue>-<slug>--<thing>`. Nothing else Pithy composes can contain it: every other segment is
195
+ * kebabbed, and a kebab never holds two hyphens in a row. So a name holding it is a feature's and a name without
196
+ * it is not — `acme`'s `staging` can never compose a feature's name, whatever its project or environment is
197
+ * called — and it ends the slug unambiguously, so `feature/12-x` binding `PROD_DB` and `feature/12-x-prod`
198
+ * binding `DB` no longer compose one database between them.
199
+ *
200
+ * It cost the slug a character: {@link MIN_LEGIBLE_SLUG} gave one up so {@link FEATURE_DERIVED_PROJECT_NAME},
201
+ * and with it every project name already accepted, stays where it was.
186
202
  */
187
- export declare const MIN_LEGIBLE_SLUG = 12;
203
+ export declare const FEATURE_TAIL_SEPARATOR = "--";
188
204
  /**
189
205
  * The binding length a feature resource name keeps verbatim at the worst legal project name.
190
206
  *
@@ -212,7 +228,7 @@ export declare const MAX_FEATURE_KIND = 2;
212
228
  */
213
229
  export declare const WORKFLOW_DERIVED_PROJECT_NAME: number;
214
230
  /**
215
- * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>-<binding>-<kind>`,
231
+ * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>--<binding>-<kind>`,
216
232
  * against R2's 63 (the strictest of the three kinds a feature provisions, and the same number a
217
233
  * feature Worker script gets).
218
234
  *
@@ -220,8 +236,8 @@ export declare const WORKFLOW_DERIVED_PROJECT_NAME: number;
220
236
  * 63 an R2 bucket name (NAMESPACE_LIMITS.r2)
221
237
  * - 2 `-f`
222
238
  * - 6 the issue number, six digits — `f999999` (MAX_ISSUE_DIGITS)
223
- * - 13 `-` + a legible slug (MIN_LEGIBLE_SLUG)
224
- * - 13 `-` + a binding kept whole (MIN_VERBATIM_BINDING)
239
+ * - 12 `-` + a legible slug (MIN_LEGIBLE_SLUG)
240
+ * - 14 `--` + a binding kept whole (FEATURE_TAIL_SEPARATOR, MIN_VERBATIM_BINDING)
225
241
  * - 3 `-` + the kind, `d1` | `kv` | `r2`
226
242
  * ----
227
243
  * 26
@@ -1 +1 @@
1
- {"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../../src/naming/limits.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;GAcG;AAEH,sFAAsF;AACtF,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,OAAO,CAAC;AAEjD;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,UAAU,CAAC;AAEnD,8FAA8F;AAC9F,MAAM,WAAW,cAAc;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,kGAAkG;IAClG,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,4FAA4F;IAC5F,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,mEAAmE;IACnE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,MAAM,CAAC;AAElC;;;GAGG;AACH,eAAO,MAAM,gBAAgB;IAC3B;;;OAGG;;iBACS,KAAK,EAAE,iBAAiB;iBAAE,SAAS,EAAE,EAAE;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,QAAQ;iBAAE,MAAM,EAAE,YAAY;;IACzG;;;;;;OAMG;;iBACO,KAAK,EAAE,sBAAsB;iBAAE,SAAS,EAAE,EAAE;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,QAAQ;iBAAE,MAAM,EAAE,YAAY;;IAC5G;;;OAGG;;iBACG,KAAK,EAAE,mBAAmB;iBAAE,SAAS,EAAE,EAAE;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,UAAU;iBAAE,MAAM,EAAE,YAAY;;IACvG;;;;OAIG;;iBAED,KAAK,EAAE,wBAAwB;iBAC/B,SAAS,EAAE,EAAE;iBACb,SAAS,EAAE,CAAC;iBACZ,MAAM,EAAE,QAAQ;iBAChB,MAAM,EAAE,YAAY;;IAEtB;;;;;OAKG;;iBACG,KAAK,EAAE,sBAAsB;iBAAE,SAAS,EAAE,GAAG;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,UAAU;iBAAE,MAAM,EAAE,YAAY;;IAC3G;;;OAGG;;iBACG,KAAK,EAAE,oBAAoB;iBAAE,SAAS;iBAAkB,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,UAAU;iBAAE,MAAM,EAAE,OAAO;;IAC/G;;;OAGG;;iBAED,KAAK,EAAE,4BAA4B;iBACnC,SAAS;iBACT,SAAS,EAAE,CAAC;iBACZ,MAAM,EAAE,UAAU;iBAClB,MAAM,EAAE,OAAO;;IAEjB;;;OAGG;;iBAED,KAAK,EAAE,6BAA6B;iBACpC,SAAS;iBACT,SAAS,EAAE,CAAC;iBACZ,MAAM,EAAE,UAAU;iBAClB,MAAM,EAAE,OAAO;;CAEgC,CAAC;AAEpD,0DAA0D;AAC1D,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,gBAAgB,CAAC;AAEtD;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAkC,CAAC;AAElE;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,8GAA8G;AAC9G,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,QAC+C,CAAC;AAE1F;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,4BAA4B,QAMjB,CAAC"}
1
+ {"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../../src/naming/limits.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;GAcG;AAEH,sFAAsF;AACtF,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,OAAO,CAAC;AAEjD;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,UAAU,CAAC;AAEnD,8FAA8F;AAC9F,MAAM,WAAW,cAAc;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,kGAAkG;IAClG,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,4FAA4F;IAC5F,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,mEAAmE;IACnE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,MAAM,CAAC;AAElC;;;GAGG;AACH,eAAO,MAAM,gBAAgB;IAC3B;;;OAGG;;iBACS,KAAK,EAAE,iBAAiB;iBAAE,SAAS,EAAE,EAAE;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,QAAQ;iBAAE,MAAM,EAAE,YAAY;;IACzG;;;;;;OAMG;;iBACO,KAAK,EAAE,sBAAsB;iBAAE,SAAS,EAAE,EAAE;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,QAAQ;iBAAE,MAAM,EAAE,YAAY;;IAC5G;;;OAGG;;iBACG,KAAK,EAAE,mBAAmB;iBAAE,SAAS,EAAE,EAAE;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,UAAU;iBAAE,MAAM,EAAE,YAAY;;IACvG;;;;OAIG;;iBAED,KAAK,EAAE,wBAAwB;iBAC/B,SAAS,EAAE,EAAE;iBACb,SAAS,EAAE,CAAC;iBACZ,MAAM,EAAE,QAAQ;iBAChB,MAAM,EAAE,YAAY;;IAEtB;;;;;OAKG;;iBACG,KAAK,EAAE,sBAAsB;iBAAE,SAAS,EAAE,GAAG;iBAAE,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,UAAU;iBAAE,MAAM,EAAE,YAAY;;IAC3G;;;OAGG;;iBACG,KAAK,EAAE,oBAAoB;iBAAE,SAAS;iBAAkB,SAAS,EAAE,CAAC;iBAAE,MAAM,EAAE,UAAU;iBAAE,MAAM,EAAE,OAAO;;IAC/G;;;OAGG;;iBAED,KAAK,EAAE,4BAA4B;iBACnC,SAAS;iBACT,SAAS,EAAE,CAAC;iBACZ,MAAM,EAAE,UAAU;iBAClB,MAAM,EAAE,OAAO;;IAEjB;;;OAGG;;iBAED,KAAK,EAAE,6BAA6B;iBACpC,SAAS;iBACT,SAAS,EAAE,CAAC;iBACZ,MAAM,EAAE,UAAU;iBAClB,MAAM,EAAE,OAAO;;CAEgC,CAAC;AAEpD,0DAA0D;AAC1D,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,gBAAgB,CAAC;AAEtD;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAkC,CAAC;AAElE;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,OAAO,CAAC;AAE3C;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,8GAA8G;AAC9G,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,QAC+C,CAAC;AAE1F;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,4BAA4B,QAMjB,CAAC"}
@@ -137,11 +137,24 @@ const MAX_ISSUE_DIGITS = 6;
137
137
  /**
138
138
  * The slug length a feature resource name keeps verbatim at the worst legal project name.
139
139
  *
140
- * Twelve, because that is where a feature slug stops being readable in a listing: below it,
141
- * `fitSegment` has to fall back to a short head plus a six-hex disambiguator, and
142
- * `acme-f95-medi-8f21c4-db-d1` tells a human nothing about which branch owns the database.
140
+ * Eleven — it was twelve until {@link FEATURE_TAIL_SEPARATOR} took a character of it (#643). About where a
141
+ * feature slug stops being readable in a listing. A slug past a feature's budget is refused, not fitted (#643),
142
+ * so this is the room the project-name cap guarantees every project's features at the worst legal input.
143
143
  */
144
- const MIN_LEGIBLE_SLUG = 12;
144
+ const MIN_LEGIBLE_SLUG = 11;
145
+ /**
146
+ * **What stands between a feature's slug and whatever it names: two hyphens (#643).**
147
+ *
148
+ * `<project>-f<issue>-<slug>--<thing>`. Nothing else Pithy composes can contain it: every other segment is
149
+ * kebabbed, and a kebab never holds two hyphens in a row. So a name holding it is a feature's and a name without
150
+ * it is not — `acme`'s `staging` can never compose a feature's name, whatever its project or environment is
151
+ * called — and it ends the slug unambiguously, so `feature/12-x` binding `PROD_DB` and `feature/12-x-prod`
152
+ * binding `DB` no longer compose one database between them.
153
+ *
154
+ * It cost the slug a character: {@link MIN_LEGIBLE_SLUG} gave one up so {@link FEATURE_DERIVED_PROJECT_NAME},
155
+ * and with it every project name already accepted, stays where it was.
156
+ */
157
+ const FEATURE_TAIL_SEPARATOR = "--";
145
158
  /**
146
159
  * The binding length a feature resource name keeps verbatim at the worst legal project name.
147
160
  *
@@ -169,7 +182,7 @@ const MAX_FEATURE_KIND = 2;
169
182
  */
170
183
  const WORKFLOW_DERIVED_PROJECT_NAME = NAMESPACE_LIMITS.workflow.maxLength - 1 - MAX_ENVIRONMENT_NAME - 1 - 22;
171
184
  /**
172
- * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>-<binding>-<kind>`,
185
+ * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>--<binding>-<kind>`,
173
186
  * against R2's 63 (the strictest of the three kinds a feature provisions, and the same number a
174
187
  * feature Worker script gets).
175
188
  *
@@ -177,13 +190,13 @@ const WORKFLOW_DERIVED_PROJECT_NAME = NAMESPACE_LIMITS.workflow.maxLength - 1 -
177
190
  * 63 an R2 bucket name (NAMESPACE_LIMITS.r2)
178
191
  * - 2 `-f`
179
192
  * - 6 the issue number, six digits — `f999999` (MAX_ISSUE_DIGITS)
180
- * - 13 `-` + a legible slug (MIN_LEGIBLE_SLUG)
181
- * - 13 `-` + a binding kept whole (MIN_VERBATIM_BINDING)
193
+ * - 12 `-` + a legible slug (MIN_LEGIBLE_SLUG)
194
+ * - 14 `--` + a binding kept whole (FEATURE_TAIL_SEPARATOR, MIN_VERBATIM_BINDING)
182
195
  * - 3 `-` + the kind, `d1` | `kv` | `r2`
183
196
  * ----
184
197
  * 26
185
198
  * ```
186
199
  */
187
- const FEATURE_DERIVED_PROJECT_NAME = NAMESPACE_LIMITS.r2.maxLength - 2 - 6 - 13 - 13 - 3;
200
+ const FEATURE_DERIVED_PROJECT_NAME = NAMESPACE_LIMITS.r2.maxLength - 2 - 6 - 12 - 14 - 3;
188
201
  //#endregion
189
- export { FEATURE_DERIVED_PROJECT_NAME, MAX_CAPABILITY_JOB, MAX_FEATURE_KIND, MAX_ISSUE_DIGITS, MAX_PITHY_NAME, MIN_LEGIBLE_SLUG, MIN_VERBATIM_BINDING, NAMESPACE_LIMITS, WORKFLOW_DERIVED_PROJECT_NAME };
202
+ export { FEATURE_DERIVED_PROJECT_NAME, FEATURE_TAIL_SEPARATOR, MAX_CAPABILITY_JOB, MAX_FEATURE_KIND, MAX_ISSUE_DIGITS, MAX_PITHY_NAME, MIN_LEGIBLE_SLUG, MIN_VERBATIM_BINDING, NAMESPACE_LIMITS, WORKFLOW_DERIVED_PROJECT_NAME };
@@ -1,5 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
+ import type { WorkflowHostNameParts } from "../workflow/naming";
3
4
  import { type FeatureIdentity, type FeatureResourceKind } from "./feature";
4
5
  /**
5
6
  * **What a provisioning run names its resources, and which `env.<name>` stanza it writes them into —
@@ -154,11 +155,22 @@ export interface ProvisionScope {
154
155
  /**
155
156
  * This scope's CF Secrets Store entry name for a declared secret.
156
157
  *
157
- * A `global` secret is the one name that does **not** take the scope's segment: it is a single
158
- * account-level value every environment binds, so every scope resolves it to the project's
159
- * `<project>-global-<secret>`. Scoping it would mint a second copy of a value defined as one.
158
+ * In a declared environment a `global` secret is the one name that does **not** take the scope's segment:
159
+ * it is a single account-level value every declared environment binds, so it resolves to the project's
160
+ * `<project>-global-<secret>`. A feature is the exception — it shares nothing with any other environment,
161
+ * so its entry for a `global` secret is its own (#643). See {@link featureScope}.
160
162
  */
161
163
  secretEntry(secret: string, secretScope: SecretNameScope): string;
164
+ /**
165
+ * What a capability's host Worker and its Workflows are named from in this scope — handed, with the
166
+ * capability, to `workflowHostName` and `workflowScriptName`. It is what an app Worker's cross-script
167
+ * `workflows` entry and the host's own config are both composed from (#643).
168
+ *
169
+ * On the scope for the reason every other name is: a declared environment composes
170
+ * `<project>-<env>-<capability>[-<job>]`, and a feature composes `<project>-f<issue>-<slug>-…`, because
171
+ * `<project>-feature-email-send` would be one Workflow every open branch deployed over every other's.
172
+ */
173
+ readonly workflowHost: Omit<WorkflowHostNameParts, "capability">;
162
174
  }
163
175
  /**
164
176
  * The same branch, one line down, for a provisioned resource: a `global` binding resolves to the
@@ -240,14 +252,16 @@ export declare function environmentScope(project: string, environment: string):
240
252
  * Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
241
253
  * in a declared environment's stanza unexpressible rather than merely discouraged.
242
254
  *
243
- * **A feature takes a binding's {@link BindingNaming} and ignores it — and that is the asymmetry against
244
- * `secretEntry` one line below, which honors `global` here as everywhere.** A global *secret* is a value
245
- * defined once for the project, so a feature binds the project's copy rather than minting a second: it is
246
- * **read**. A global *database* is a resource a feature would **migrate** — `provisionFeature` runs
247
- * `pithy migrate` against everything it names — so honoring `global` here would point one branch's
248
- * schema changes at the project's live suppression list, and point teardown at it afterwards. A feature
249
- * owns every resource it names, exactly so that destroying the feature destroys them all; there is no
250
- * shared name to match and nothing for teardown to spare.
255
+ * **A feature takes a binding's {@link BindingNaming} and ignores it, and a secret's `global` scope with it.**
256
+ * A global *database* is a resource a feature would **migrate** — `provisionFeature` runs `pithy migrate`
257
+ * against everything it names — so honoring `global` would point one branch's schema changes at the project's
258
+ * live suppression list, and point teardown at it afterwards. A global *secret* went the other way until #643:
259
+ * a feature bound the project's copy. It no longer does. **Nothing is shared between a feature and any other
260
+ * feature, staging or prod** — the account and its one Secrets Store are the only containers that cannot be
261
+ * split, and every entry inside the store is split. So a feature names its own entry for a `global` secret
262
+ * too: a mintable one is minted for it, and a supplied one is reported missing under the feature's name rather
263
+ * than bound to the value every environment reads. A feature owns everything it names, exactly so that
264
+ * destroying the feature destroys it all; there is no shared name to match and nothing for teardown to spare.
251
265
  *
252
266
  * `resource` is ignored for the same reason it is moot: a feature's `<thing>` segment is the binding plus
253
267
  * the kind, and the whole name is recomputed on teardown rather than looked up, so nothing outside the
@@ -1 +1 @@
1
- {"version":3,"file":"provisionScope.d.ts","sourceRoot":"","sources":["../../src/naming/provisionScope.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EAIzB,MAAM,WAAW,CAAC;AAInB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;CAC1B;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,oBAAoB;IACnC,2GAA2G;IAC3G,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAAC;IACpF;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,MAAM,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChE;;;;;;OAMG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,GAAG,MAAM,CAAC;CACnE;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,mBAAmB,GAC1B,MAAM,CAAC;AACV,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,CAAC;AAsBV;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAKpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,cAAc,CA6BrF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,cAAc,CAetE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,oBAAoB,GAAG,MAAM,EAAE,CAI1G"}
1
+ {"version":3,"file":"provisionScope.d.ts","sourceRoot":"","sources":["../../src/naming/provisionScope.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAEhE,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EAIzB,MAAM,WAAW,CAAC;AAInB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;CAC1B;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,oBAAoB;IACnC,2GAA2G;IAC3G,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAAC;IACpF;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,MAAM,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChE;;;;;;;OAOG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,GAAG,MAAM,CAAC;IAClE;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,IAAI,CAAC,qBAAqB,EAAE,YAAY,CAAC,CAAC;CAClE;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,mBAAmB,GAC1B,MAAM,CAAC;AACV,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,CAAC;AAsBV;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAKpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,cAAc,CA8BrF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,cAAc,CAiBtE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,oBAAoB,GAAG,MAAM,EAAE,CAY1G"}
@@ -97,7 +97,11 @@ function environmentScope(project, environment) {
97
97
  });
98
98
  return name;
99
99
  },
100
- secretEntry: (secret, secretScope) => secretEntryName(project, secret, secretScope, () => names.secretEntry(secret))
100
+ secretEntry: (secret, secretScope) => secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
101
+ workflowHost: {
102
+ project,
103
+ env: environment
104
+ }
101
105
  };
102
106
  }
103
107
  /**
@@ -107,14 +111,16 @@ function environmentScope(project, environment) {
107
111
  * Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
108
112
  * in a declared environment's stanza unexpressible rather than merely discouraged.
109
113
  *
110
- * **A feature takes a binding's {@link BindingNaming} and ignores it — and that is the asymmetry against
111
- * `secretEntry` one line below, which honors `global` here as everywhere.** A global *secret* is a value
112
- * defined once for the project, so a feature binds the project's copy rather than minting a second: it is
113
- * **read**. A global *database* is a resource a feature would **migrate** — `provisionFeature` runs
114
- * `pithy migrate` against everything it names — so honoring `global` here would point one branch's
115
- * schema changes at the project's live suppression list, and point teardown at it afterwards. A feature
116
- * owns every resource it names, exactly so that destroying the feature destroys them all; there is no
117
- * shared name to match and nothing for teardown to spare.
114
+ * **A feature takes a binding's {@link BindingNaming} and ignores it, and a secret's `global` scope with it.**
115
+ * A global *database* is a resource a feature would **migrate** — `provisionFeature` runs `pithy migrate`
116
+ * against everything it names — so honoring `global` would point one branch's schema changes at the project's
117
+ * live suppression list, and point teardown at it afterwards. A global *secret* went the other way until #643:
118
+ * a feature bound the project's copy. It no longer does. **Nothing is shared between a feature and any other
119
+ * feature, staging or prod** — the account and its one Secrets Store are the only containers that cannot be
120
+ * split, and every entry inside the store is split. So a feature names its own entry for a `global` secret
121
+ * too: a mintable one is minted for it, and a supplied one is reported missing under the feature's name rather
122
+ * than bound to the value every environment reads. A feature owns everything it names, exactly so that
123
+ * destroying the feature destroys it all; there is no shared name to match and nothing for teardown to spare.
118
124
  *
119
125
  * `resource` is ignored for the same reason it is moot: a feature's `<thing>` segment is the binding plus
120
126
  * the kind, and the whole name is recomputed on teardown rather than looked up, so nothing outside the
@@ -133,7 +139,12 @@ function featureScope(identity) {
133
139
  honorsGlobal: false,
134
140
  resource: (binding, kind) => featureResourceName(identity, binding, kind),
135
141
  worker: ({ app }) => featureWorkerName(identity, app),
136
- secretEntry: (secret, secretScope) => secretEntryName(identity.project, secret, secretScope, () => featureSecretEntryName(identity, secret))
142
+ secretEntry: (secret) => featureSecretEntryName(identity, secret),
143
+ workflowHost: {
144
+ project: identity.project,
145
+ env: FEATURE_ENVIRONMENT,
146
+ feature: identity
147
+ }
137
148
  };
138
149
  }
139
150
  /**
@@ -160,7 +171,13 @@ function featureScope(identity) {
160
171
  */
161
172
  function featureWorkerScriptNames(identity, worker) {
162
173
  const current = featureScope(identity).worker(worker);
163
- const beforeSingleProject = featureWorkerName(identity, worker.script);
174
+ let beforeSingleProject;
175
+ try {
176
+ beforeSingleProject = featureWorkerName(identity, worker.script);
177
+ } catch (error) {
178
+ if (error instanceof ValidationError) return [current];
179
+ throw error;
180
+ }
164
181
  return current === beforeSingleProject ? [current] : [current, beforeSingleProject];
165
182
  }
166
183
  //#endregion
@@ -145,8 +145,10 @@ export interface SeedPrepareContext {
145
145
  /** The project name (the root `pithy.config.ts` `name`), for messages and per-project lookups. */
146
146
  project: string;
147
147
  /**
148
- * Where this Worker answers locally — `http://localhost:8807` and the like. `null` outside `dev`, and
149
- * `null` in a checkout that was never allocated a port block.
148
+ * Where this Worker answers — in `dev`, locally, `http://localhost:8807` and the like; off `dev`, its own
149
+ * address (#643): a declared environment's declared one, a feature's `https://<script>.<subdomain>.workers.dev`.
150
+ * `pithy seed --host` overrides it. `null` in a checkout that was never allocated a port block, and for a
151
+ * deployed Worker with no address at all.
150
152
  *
151
153
  * The one address a fixture cannot write down. A dev port is *allocated*, not configured: a checkout
152
154
  * reserves a block and pins one port per Worker into `.dev.config.json`, so `http://localhost:8787` is
@@ -163,17 +165,44 @@ export interface SeedPrepareContext {
163
165
  * moves. Identity must not.
164
166
  *
165
167
  * **`null` is an answer, never a guess.** A plain clone that has never run `pithy dev`, a Worker added
166
- * after the block was pinned, or any environment but `dev` — a deployed environment's address is
167
- * declared rather than allocated, and `pithy env` is what answers it. An invented origin would be
168
- * indistinguishable from a real one, so a set that cannot work without one refuses and says so.
168
+ * after the block was pinned, or a deployed Worker that declares no address and has none to derive. An
169
+ * invented origin would be indistinguishable from a real one, so a set that cannot work without one
170
+ * refuses and says so.
169
171
  */
170
172
  origin: string | null;
171
173
  /**
172
174
  * Read one of this environment's secrets by name, or `undefined` when it is not set. Local dev resolves
173
- * every secret from `.dev.vars`, so this answers there; a deployed environment's secrets are not on the
174
- * operator's disk, and a set that needs one must therefore be `dev`-only.
175
+ * every secret from the dev secrets file, so this answers there; a deployed environment's secrets are not
176
+ * on the operator's disk, so there it refuses, and a set that needs one must be `dev`-only. A feature is a
177
+ * deployed environment here too (#643): its secrets exist only in its own Cloudflare stores, which the CLI
178
+ * can write and never read.
175
179
  */
176
180
  secret: (name: string) => Promise<string | undefined>;
181
+ /**
182
+ * The value **this run just minted** for a secret, or `undefined` — never what the environment holds.
183
+ *
184
+ * `pithy provision` creates a random value for every `cf-secrets-store` secret the registry declares
185
+ * mintable, writes it into the account's Secrets Store, and lets it go. The Secrets Store is write-only
186
+ * from the CLI, so that write is the only moment the value exists anywhere the toolchain can see it —
187
+ * and provisioning migrates and seeds in the same process, a few steps later. A fixture that must
188
+ * *seal* something at creation time (encrypt a private key under the environment's key-encryption
189
+ * secret, derive an id that the running app will re-derive) can only do it then. Afterwards the value
190
+ * is unrecoverable by design.
191
+ *
192
+ * **This is not {@link secret}, and the two are not interchangeable.** `secret` answers what the
193
+ * environment holds and refuses outside `dev`, absolutely (#159); that rule is untouched. This answers
194
+ * the strictly narrower question *did this run create one, a moment ago, in memory*. A set asking here
195
+ * for a secret this run did not mint gets `undefined` wherever it runs, in `dev` or anywhere else.
196
+ *
197
+ * **Synchronous, because it cannot fetch.** There is no file to open and no request to make; a promise
198
+ * in this signature would suggest otherwise.
199
+ *
200
+ * **Empty is ordinary.** A re-run mints nothing — absence is checked before anything is generated — and
201
+ * a standalone `pithy seed` mints nothing at all. Both answer `undefined` for every name, and neither
202
+ * is an error. A set that needs a minted value and is handed none says so itself; nothing is invented
203
+ * on its behalf.
204
+ */
205
+ mintedThisRun: (name: string) => string | undefined;
177
206
  /**
178
207
  * The developer's machine-local preferences for this project, read from the Pithy config directory
179
208
  * (`~/.config/pithy/<project>/dev.json`, or `%APPDATA%\pithy\<project>\dev.json` on Windows), parsed but