diff --git a/.claude/skills/ai-diataxis-scaffold/SKILL.md b/.claude/skills/ai-diataxis-scaffold/SKILL.md
index e542c35..1c05706 100644
--- a/.claude/skills/ai-diataxis-scaffold/SKILL.md
+++ b/.claude/skills/ai-diataxis-scaffold/SKILL.md
@@ -40,6 +40,17 @@ before you run.**
cannot adopt a structure they have never seen, and that every directory here
ships with a page in it.
+**The same disclaimer covers the second axis.** When step 2's audience question is
+answered with anything but its default, this skill builds a tree partitioned by
+reader as well as by mode. The one upstream page that raises that shape —
+
+(snapshot captured 2026-08-02, retrieved 2026-08-24; the live URL returns 404 as of
+2026-08-24, so the guidance is published-but-withdrawn) — **presents it only as a
+question it declines to answer**: "Which better? There seems to be a lot of
+repetition in either cases." So claim no source support for the two-axis shape
+either. State that Diátaxis does not endorse it, name that page as raising and
+declining it, and say the argument for offering it is this project's own.
+
**So: never create a directory you do not also seed.** That is not a stylistic
preference. Git does not track empty directories, so four empty directories
produce zero commits and there is nothing to hand over; and an autogenerated
@@ -95,6 +106,40 @@ On confirmation, write `docs_path` into `.context/README.md` frontmatter with th
Edit tool and report that the interview will not repeat. If the user declines, use
the value for this run only.
+### Step 2b: Ask the audience question — once, before anything is created
+
+**Ask this in step 2 and not later.** Step 5 creates directories, and the above-mode
+shape cannot be adopted after that: once `docs_path/how-to/` holds six pages, moving
+to `user-docs/how-to/` relocates every page and breaks every cross-mode index link.
+The one moment the choice costs nothing is before the tree exists.
+
+Use `AskUserQuestion` with three options:
+
+- **No audiences** *(recommended)* — the four mode directories go directly under
+ `docs_path`. One readership, which is the ordinary case.
+- **A user tree and a developer tree** — two audience roots, each holding its own
+ four mode directories.
+- **Something else** — the builder names their audiences.
+
+Write the question to the same standard step 2 already sets for the docs root: say
+that **the answer determines where the four mode directories go**, name what will be
+created under each option, and say that **changing it later moves every page.**
+
+**The options are examples of a shape, not a vocabulary.** `user` and `developer` are
+offered because they are the common pair; a project that picks "something else" names
+its own labels with no suggestion from this skill. An audience has a **label** — the
+project's own word — and an **object**, the thing that audience's readers act on
+(*the released command-line tool*, *this repository's source tree*). **The object is
+never a person**, and it is what the landing page seed needs. The rule in full is
+reference §12 in
+`.claude/skills/ai-skills-reference/diataxis-classification.md`.
+
+**Do not write the answer into `.context/README.md`.** `/ai-init` is the sole writer
+of that file's body, and `## Audiences` is never interviewed for there. Instead, when
+the answer is anything but the default, **print the `## Audiences` section for the
+user to paste**, one bullet per audience carrying its label and the object its readers
+act on.
+
## Step 3: Detect the static-site generator
Run the detection table in `framework-detection.md` again, this time for the
@@ -133,6 +178,32 @@ generator: Docusaurus (docs/docusaurus.config.ts)
Creating 2 directories, 2 index pages, 5 template files. Writing 0 existing files.
```
+**When step 2b returned audiences, report the whole shape in the same form**, and
+report it **before writing anything** — the count is the number most worth seeing
+early, because it is more than double the default:
+
+```text
+docs_path: docs/docs (from .context/README.md)
+generator: Docusaurus (docs/docusaurus.config.ts)
+audiences: user, developer (from the step 2b answer)
+
+ user-docs/ missing — will create + seed index.md
+ user-docs/tutorial/ missing — will create + seed index.md
+ user-docs/how-to/ missing — will create + seed index.md
+ user-docs/reference/ missing — will create + seed index.md
+ user-docs/explanation/ missing — will create + seed index.md
+ developer-docs/ missing — will create + seed index.md
+ developer-docs/tutorial/ missing — will create + seed index.md
+ developer-docs/how-to/ missing — will create + seed index.md
+ developer-docs/reference/ missing — will create + seed index.md
+ developer-docs/explanation/ missing — will create + seed index.md
+ _templates/ missing — will copy 5 templates
+ ../CLAUDE.md missing — will write
+
+Creating 10 directories, 10 index pages, 5 template files. Writing 0 existing files.
+Against 4 directories and 4 pages for the no-audiences answer.
+```
+
**Never overwrite.** Where a target file already exists, warn and ask whether to
overwrite or skip; default to skip. An existing page must be byte-identical after
the run.
@@ -148,10 +219,58 @@ say plainly that moving existing pages into modes is not this skill's job:
## Step 5: Create the missing directories and seed each one
-For each of the four modes that is missing, create the directory **and** write its
-`index.md`. The four seed shapes are in `references/seeds.md` — read it and use
-them; they carry the frontmatter keys, the reader-facing paragraph, the fenced
-worked example, and the cross-mode links.
+Branch on step 2b's answer. The two branches share every non-negotiable below.
+
+### 5a. The default answer — no audiences
+
+Create the four mode directories directly under `docs_path`, seed each one, and
+**change nothing else.** This path must be **byte-identical to a run of this skill
+before the audience question existed**: four directories, four index pages, and **no
+output line naming an audience, a readership, a shape, or a second axis.** Not even
+to say the question was asked and declined.
+
+That is the point rather than a nicety. A builder who answered "no audiences" has one
+readership, and a scaffold that then tells them about a distinction selecting nothing
+has reintroduced exactly the noise the axis was designed to avoid.
+
+### 5b. Any other answer — one root per audience
+
+Create **one audience root per declared audience**, each holding the four seeded mode
+directories, plus **one landing page per root**. For two audiences that is ten
+directories and ten pages, reported in step 4's form before any of it is written.
+
+Then **print the registration lines and stop there**:
+
+- **Docusaurus** — one sidebar entry and one navbar item per root:
+
+ ```text
+ // sidebars.ts — one per audience root
+ userDocsSidebar: [{type: 'autogenerated', dirName: 'user-docs'}],
+ developerDocsSidebar: [{type: 'autogenerated', dirName: 'developer-docs'}],
+
+ // docusaurus.config.ts — navbar.items, one per audience root
+ { type: 'docSidebar', sidebarId: 'userDocsSidebar', position: 'left', label: 'User docs' },
+ { type: 'docSidebar', sidebarId: 'developerDocsSidebar', position: 'left', label: 'Developer docs' },
+ ```
+
+- **Another generator, or none detected** — name what it registers a top-level tree
+ with, or say that no generator was detected and the tree will not render at all.
+
+**Edit no configuration file, and say the tree is unreachable from the site's
+navigation until those lines are added.** This is the failure most easily produced by
+being helpful: pages that exist, build, and no reader can reach. Reference §12 states
+the rule — a skill never creates an audience root it cannot register, so a skill that
+creates one hands over the registration.
+
+**Repeat the no-endorsement disclaimer here**, in one line, naming the withdrawn
+upstream page as raising the two-axis shape and declining to recommend it.
+
+### Seeding, both branches
+
+The four mode seed shapes are in `references/seeds.md` — read it and use them; they
+carry the frontmatter keys, the reader-facing paragraph, the fenced worked example,
+and the cross-mode links. The **audience landing page** is the fifth shape in that
+same file, used only in 5b.
Non-negotiables, all checked in step 8:
@@ -198,9 +317,21 @@ else:
directory becomes a page, so the agent-context file would publish as
documentation.
-The content is in `references/seeds.md`. Then **print the `AGENTS.md` snippet
-rather than writing it** — no skill in this collection writes that file; `/ai-init`
-prints one too.
+The content is in `references/seeds.md`, and **which variant you use follows step
+2b**:
+
+- **Default answer (no audiences)** → use the docs-tree `CLAUDE.md` seed **exactly as
+ written, byte for byte.** It gains nothing about audiences, shapes, or thresholds.
+- **Any other answer** → use the audience variant in the same file. It adds the
+ audience paths filled in from the interview answer, plus the two lines a two-axis
+ tree needs that a flat one does not: **a page's home is its mode**, and **a mode
+ index gets grouped only once its list passes seven items.**
+
+Both lines are conditional for the same reason 5a is byte-identical: the default path
+must not acquire prose about an axis the builder declined.
+
+Then **print the `AGENTS.md` snippet rather than writing it** — no skill in this
+collection writes that file; `/ai-init` prints one too.
## Step 8: Self-audit
@@ -215,6 +346,22 @@ build: `onBrokenMarkdownLinks` defaults to `warn`, so a page carrying an unfille
failure this skill's whole justification rests on not happening.
- **Every file you did not intend to touch is unchanged.**
+When step 2b returned audiences, also check:
+
+- **Every audience root holds at least one landing page and four seeded mode
+ directories.** A root with modes and no landing page has no entry point; a root with
+ a landing page and no modes is an empty structure by another name.
+- **Every cross-mode link inside a root resolves within that root.** `../how-to/` from
+ `user-docs/tutorial/index.md` must reach `user-docs/how-to/`, never
+ `docs_path/how-to/`. A link that escapes its root sends a reader to the other
+ audience's tree, and the site build will not catch it because the target exists.
+- **No landing page contains a `{`.** The registration lines you printed do contain
+ braces; they are printed output, not an emitted page, so the rule does not reach
+ them — but it does reach every landing page, and that is the one seed shape written
+ from the interview answer rather than copied.
+- **No configuration file was modified.** `sidebars.ts`, `docusaurus.config.ts`, and
+ their equivalents must be byte-identical after the run.
+
Fix anything that fails before reporting completion.
## Report
diff --git a/.claude/skills/ai-diataxis-scaffold/references/seeds.md b/.claude/skills/ai-diataxis-scaffold/references/seeds.md
index 42faca4..cde5c7f 100644
--- a/.claude/skills/ai-diataxis-scaffold/references/seeds.md
+++ b/.claude/skills/ai-diataxis-scaffold/references/seeds.md
@@ -1,14 +1,23 @@
# Mode Index Seed Shapes
-The four index pages the scaffold skill writes, one per Diátaxis mode. Each is a
-complete page, not a fragment: a reader who opens a freshly scaffolded tree finds
-prose that tells them what the mode is for and one example of a page in it.
-
-Every seed below is **generic by design**. It describes its mode in reader terms
-and carries a fenced example with no project specifics, because the scaffold runs
-before any page exists to be specific about. Project-specific pages come from
+The pages the scaffold skill writes: **four mode index pages**, one per Diátaxis
+mode, and — only when the audience question returned audiences — **one landing page
+per audience root**. Each is a complete page, not a fragment: a reader who opens a
+freshly scaffolded tree finds prose that tells them what the mode is for and one
+example of a page in it.
+
+The four mode seeds are **generic by design**. Each describes its mode in reader
+terms and carries a fenced example with no project specifics, because the scaffold
+runs before any page exists to be specific about. Project-specific pages come from
`ai-diataxis` CREATE afterwards.
+**The audience landing seed is the one exception, and it is a shape rather than a
+copyable block.** Its content is *this* project's audience label and the object that
+audience's readers act on, both of which come from the interview answer, so there is
+nothing generic to copy. It uses `` slots and every one of them is
+filled before the page is written — the no-`{` rule below covers braces, and an
+emitted page carries no unfilled angle brackets either.
+
This file is shared byte-identically by both runtimes, so it names skills without
a leading slash and cites no path that only one runtime has.
@@ -162,6 +171,59 @@ For the steps to do something, see [how-to](../how-to/). For what a setting
does, see [reference](../reference/).
````
+## Seed: the audience landing page
+
+**Used only when the audience question returned audiences.** One per audience root,
+written at `/index.md`. A shape to fill rather than a block to copy:
+`` is the label the builder gave, and `` is the thing that
+audience's readers act on — *the released command-line tool*, *this repository's
+source tree*. **The object is never a person.**
+
+````markdown
+---
+title: ""
+sidebar_label: ""
+sidebar_position: 1
+---
+
+# documentation
+
+For readers working on . Everything in this section is written for that
+work; documentation for a different reader lives in its own section.
+
+The four kinds of page here answer four different questions:
+
+- [Tutorial](tutorial/) — teach me to use for the first time
+- [How-to guides](how-to/) — I already know what I want to do; show me the steps
+- [Reference](reference/) — tell me exactly what this option does
+- [Explanation](explanation/) — help me understand why it works this way
+
+A page belongs to one of those four and to no other. Which one it belongs to
+depends on what the reader needs at that moment, not on what the page is about.
+````
+
+**Filling it:**
+
+- **Replace every ``.** A finished page contains no `` placeholder and
+ no `{` character. Both are checked at skill step 8.
+- **The mode links are relative to the root** — `tutorial/`, not `../tutorial/`.
+ This page sits one level *above* the mode directories, where a mode index sits
+ inside one. Getting this wrong sends every link to the other audience's tree, or
+ out of the docs root entirely.
+- **Name the object in the builder's own words from the interview, never a job
+ title:** "the released command-line tool", not "end users". A label names the
+ audience; the object names what they act on, and the two are not
+ interchangeable.
+- **Add a cross-link to the other roots only if there is more than one, and only to
+ roots this run created.** A link to a tree that does not exist fails the build
+ under `onBrokenLinks: 'throw'`.
+- **Expect the modes to sort alphabetically in the sidebar, not in Diátaxis
+ order.** All four mode index pages carry `sidebar_position: 1`, so inside one
+ audience root they tie and an autogenerated sidebar falls back to alphabetical —
+ explanation, how-to, reference, tutorial. Say so when handing over the
+ registration lines. Renumbering them is not available here: the four mode seeds
+ are shared with the no-audiences answer, which must stay byte-identical.
+
## The docs-tree `CLAUDE.md`
Written to the **parent** of `docs_path` when that parent is inside the docs site
@@ -192,6 +254,54 @@ implicit "About". Tutorial and reference titles carry no pattern.
name begins with `_` are excluded from the build by convention.
```
+### The audience variant
+
+**Used only when the audience question returned audiences**, in place of the block
+above — never in addition to it. On the default answer the block above is written
+exactly as it stands, so that path acquires no prose about an axis the builder
+declined.
+
+Fill one `- /` line per audience root from the interview answer.
+
+```markdown
+# /
+
+Documentation content, partitioned first by **audience** and then by
+[Diátaxis](https://diataxis.fr) mode — one directory per audience, four per audience.
+
+- / — for readers working on
+- / — for readers working on
+
+Inside each audience directory:
+
+- `tutorial/` — learning-oriented: one guided path, no choices
+- `how-to/` — task-oriented: the reader brings the goal
+- `reference/` — information-oriented: mirrors the product
+- `explanation/` — understanding-oriented: why, not how
+
+A page's home is its **mode**. The audience directory says who the page is for; the
+mode directory inside it says what kind of page it is, and it is the mode that
+decides which of the four directories a page goes in. A page serves exactly one
+mode. When a page starts serving two, split it rather than adding a section — the
+mode a reader is in determines what they can absorb.
+
+Put a new page in the audience whose readers act on the thing it describes, then in
+the directory matching what those readers need — not what the content is about.
+
+A mode index lists its pages flatly and gets grouped only once that list passes
+seven items. Below that, grouping costs a reader more than the flat list does.
+
+A how-to title begins "How to". An explanation title reads naturally after an
+implicit "About". Tutorial and reference titles carry no pattern.
+
+`_templates/` holds the page templates and is not published. Directories whose
+name begins with `_` are excluded from the build by convention.
+```
+
+Replace every `` before writing. This file is agent context rather than a
+published page, so the no-`{` rule does not reach it — but an unfilled slot here
+misinforms every agent that reads it, which is worse than a broken build.
+
## The `AGENTS.md` snippet
Always printed, never written — no skill in this collection writes that file.
diff --git a/.claude/skills/ai-diataxis/SKILL.md b/.claude/skills/ai-diataxis/SKILL.md
index 6572a8a..5727fb0 100644
--- a/.claude/skills/ai-diataxis/SKILL.md
+++ b/.claude/skills/ai-diataxis/SKILL.md
@@ -69,8 +69,21 @@ Read `.context/README.md` and resolve `docs_path` from its frontmatter:
the Edit tool, and report that the interview will not repeat. Do not block if
the user declines — proceed with the detected value for this run only.
+**Read the placement convention unconditionally.** Separately from resolving
+`docs_path`, read `dirname(docs_path)/CLAUDE.md` **by path** — and the project
+root's `CLAUDE.md` when that is a different file. This is rank 3 of the audience
+resolution ladder (reference §12.3), and it is **not** conditional on `docs_path`
+being absent: a project can carry `docs_path` in its context file and record where
+a new page goes only in prose, so a read that fires only on a missing `docs_path`
+never consults the top-ranked signal.
+`.claude/skills/ai-skills-reference/framework-detection.md` ranks this signal
+first for that reason. Do not rely on either file being in ambient context.
+
Also read the project's Voice, Key Terms, Principles, and Constraints from
-`.context/README.md` — CREATE applies them to every page it writes.
+`.context/README.md` — CREATE applies them to every page it writes. Read its
+`## Audiences` section too, if it has one (reference §12.2). **Absence is the
+normal case and is not a warning** — a project that declares no audiences has one,
+and the axis selects nothing there.
### The walk exclusion rule
@@ -153,6 +166,37 @@ also wrong" the compass warns about. The gate always stops and asks.
The request describes content that does not exist yet. Produce exactly one page,
in exactly one mode directory.
+### Step C0: Check which root the request belongs to
+
+**Run this before C1.** A request that should never become a published page must
+not be classified either — classifying it assigns a mode, and a mode assignment is
+what sends it into the tree.
+
+Reference §13 lists four documentation roots. This skill writes to exactly one of
+them, the published docs tree. If the request names an artifact belonging to one
+of the other three, **write no page** and report which skill owns it:
+
+| The request names | Owner | Root |
+|---|---|---|
+| how a feature, subsystem, or module was actually built | `/ai-as-built` | the working root |
+| an architectural decision, or a record of one | `/ai-adr` | the decision log |
+| agent context for a source directory — a `CLAUDE.md`, or a directory's own `README.md` | `/ai-context` | agent context, by proximity |
+| research, a plan, a design, an outline, or a draft | the working-root pipeline — `/ai-research`, `/ai-architect`, `/ai-plan`, `/ai-outline`, `/ai-draft` | the working root |
+
+"Document how we built the release pipeline" is the case this step exists for. It
+reads as a documentation request, resolves to no existing path, and therefore
+reaches CREATE — where Mode Detection has already branched on path resolution
+alone and nothing has yet asked which root it belongs to. Name `/ai-as-built` and
+stop.
+
+**One test, so this step does not swallow legitimate work.** A request for a page
+*about* one of these things is still a page request: "explain why we keep a
+decision log" is an explanation page, not a decision record. The discriminator is
+whether the request asks for **the artifact itself** or for **a page addressed to
+a reader who came to the site.** When it reads as both, ask with
+`AskUserQuestion`, offering the two readings — the same posture Mode Detection
+takes on an ambiguous argument.
+
### Step C1: Classify the request
Run **Shared: Classify** on the request text.
@@ -164,13 +208,53 @@ caching works and show me how to configure it" (cognition/acquisition +
action/application) — stops here and proposes two cross-linked pages. Write
nothing until the user confirms.
+### Step C2b: Resolve the audience
+
+**After the gate and before placement.** It cannot live in Mode Detection, which
+resolves `$ARGUMENTS` against `docs_path` first and would then be reading the same
+argument twice for two different purposes.
+
+1. Work down the **five-rank ladder** in reference §12.3, stopping at the first
+ rank that decides: the request's own words (matched by *position* — `for the
+ `, `-facing`, `in docs`, a leading ` docs:`), the
+ `## Audiences` declaration, the placement convention read in Context Loading,
+ the root detector, then the object test.
+2. Run the **audience-root detector** (reference §12.4) on `docs_path`, so rank 4
+ has an answer whether or not the project declared anything.
+3. **Ask with `AskUserQuestion` on zero matches and on two.** Never guess, and
+ never resolve a near-miss to the nearest label — reference §12.3 forbids fuzzy
+ matching because a wrongly resolved audience produces no error anywhere.
+
+**When no audience is known anywhere and the request named none, resolve to
+`none` and print nothing about the axis.** Not a warning, not a suggestion, not a
+mention. This is the ordinary single-audience project, and telling it about a
+distinction that selects nothing is the noise this axis was designed to avoid.
+
### Step C3: Place the page
-For the single confirmed mode, ensure its directory exists under `docs_path`
-(`tutorial/`, `how-to/`, `reference/`, or `explanation/`). **Create only that
-one directory if it is missing. Never scaffold the other three** — the
-anti-scaffold rule (reference §6) names empty mode directories as the practice
-Diátaxis exists to prevent; a mode directory exists because a page needed it.
+Place at **one of three paths**, according to what C2b resolved:
+
+| C2b resolved | Page goes to |
+|---|---|
+| an audience whose root sits above the modes | `//.md` |
+| an audience that partitions from below | `docs_path///.md` |
+| `none`, or an audience with no directory | `docs_path//.md` |
+
+**Create only the one directory the classified page needs.** Never scaffold the
+other three modes — the anti-scaffold rule (reference §6) names empty mode
+directories as the practice Diátaxis exists to prevent; a mode directory exists
+because a page needed it.
+
+**CREATE never creates an audience root.** An above-the-modes root needs a sidebar
+entry and a navigation item that no skill in this collection writes (reference
+§12.5), so creating one here would produce a page no reader can reach. When the
+resolved audience has no directory, place at the mode root and report that.
+
+**When the request named an audience and the project has none**, place at the mode
+root and print **one line** saying so and naming how to declare one — a
+`## Audiences` section in `.context/README.md` (reference §12.2). Not silence, and
+not a question: the builder raised the audience, so answering is not unsolicited
+noise, and staying silent would look like the phrase had been honoured.
### Step C4: Instantiate the template
@@ -187,12 +271,24 @@ Then:
- Apply the project's Voice, Key Terms, and Principles from context loading.
- Write the page at `docs_path//.md`.
-### Step C5: Self-audit and link
+### Step C5: Self-audit, report the resolution, and link
Confirm: one mode only; title rule satisfied; no `{` remains; the page uses the
matching template's sections. Then link the new page from its section index so
it is not an orphan.
+**Report the resolved audience and the rank that resolved it — one line, every
+run.** For example: `audience: developer, resolved from the request`. When C2b
+resolved to `none` because the project declares no audiences and the request named
+none, this line is **omitted entirely** rather than printed as "audience: none".
+
+Treat the line as a hard requirement, not a nicety. A page filed under the wrong
+reader renders correctly, links correctly, and passes `npm run build` — so nothing
+downstream can fail on it, and this report is **the only check the axis has.** An
+implementation that drops it to save a line has removed the feature's entire
+verification. When the resolution is wrong, the user re-runs with an explicit
+`for the ` phrase; this step reports and never offers to move the page.
+
---
## ASSESS Mode
@@ -244,6 +340,22 @@ Run **Shared: Classify** on each collected page. Build a table ranked by
ranked low.
- **Exclude connective pages outright** — they never enter the table.
+Then take two measurements of the tree's *shape*, separately from the table:
+
+1. **Count each mode directory's contents-list items against seven.** An item is
+ one entry per page or per subdirectory, **excluding the directory's own index**,
+ counted **non-recursively** (reference §12.6). Use the fence-aware extraction
+ reference §10 requires — a naive scan is unreliable here, because
+ `grep -m1 '^title:'` reports `ai-util-export-pdf.md` as titled "Quarterly
+ Review" from inside a fenced example. Record every mode whose count is
+ **greater than** seven; seven itself does not qualify.
+2. **Run the audience-root detector** (reference §12.4) on `docs_path`. Record what
+ it returns, including when it returns zero.
+
+Report the per-mode counts either way. On this repository they are 1 / 6 / 4 / 5
+for tutorial / how-to / reference / explanation, and the detector returns zero — so
+neither measurement produces an action, and saying so is the result.
+
### Step S3: Propose exactly one next action
Propose **one** next action on **one** page — the highest-severity conflation.
@@ -252,6 +364,26 @@ immediately" ( ). Do not propose a
batch of fixes; one page, one action, regardless of how many conflations the
table holds.
+**A grouping proposal ranks strictly below every outstanding conflation.** Emit it
+as the single next action only when **no** conflation remains anywhere in the
+table. A page serving two modes is a Diátaxis defect; a nine-item index is a
+readability signal, and the second never displaces the first. The one-action-only
+rule is unchanged by this: a run with a conflation left proposes that conflation
+and stays silent about the count.
+
+When a grouping *is* the action, propose one of the two remedies reference §12.6
+takes from its source — a landing-page prose grouping, or a nested directory
+inside that one mode — **show which pages fall under each group, and make no edit
+to any file until the user confirms.**
+
+**An undeclared audience root is reported and asked about, never adopted.** When
+the detector finds a root the project has not declared in `## Audiences`, say what
+was found and ask whether it is an audience — the posture `adr-format.md:82` takes
+for an unrecognised status, where both silent defaults are wrong. Adopting it
+would infer a taxonomy the project never stated. **Never propose migrating an
+existing flat tree into an audience shape:** that moves every page and breaks
+every cross-mode index link, and it is a human's deliberate choice.
+
---
## Report
@@ -276,6 +408,15 @@ Close every run by naming all three modes so SURVEY stays discoverable, the way
than leaving it empty.
- **No auto-splitting.** The gate always stops and asks; it never rewrites a
page on its own confidence.
+- **No audience root, ever.** CREATE places at a mode root and reports when the
+ resolved audience has no directory. An above-the-modes root needs a sidebar entry
+ and a navigation item this skill does not write (reference §12.5), so creating
+ one would produce pages no reader can reach. `/ai-diataxis-scaffold` is the only
+ skill that creates one, and it prints those registration lines.
+- **No migration into an audience shape, and no grouping at seven.** SURVEY reports
+ an undeclared audience root and asks; it never adopts one and never proposes
+ moving an existing flat tree. The grouping trigger fires **above** seven items,
+ so a seven-item index is left alone (reference §12.6).
- **No functional-quality judgement.** It enforces structure and single-mode
purity. It does not and cannot verify that a tutorial teaches, a how-to's
steps work, or reference is accurate or complete (reference §7).
diff --git a/.claude/skills/ai-init/SKILL.md b/.claude/skills/ai-init/SKILL.md
index 39eb9e6..9a02761 100644
--- a/.claude/skills/ai-init/SKILL.md
+++ b/.claude/skills/ai-init/SKILL.md
@@ -226,6 +226,8 @@ Analyze each section:
`Voice` and `Principles` are deliberately absent from this table. They ship with a fixed default, so a populated Voice section is never a gap — assessing it would re-interview a decision the skill already made.
+`## Audiences` is deliberately absent too, for a different reason: it is **optional, and this skill never interviews for it.** It is a top-level section declaring the readerships a project partitions its docs tree by, which `/ai-diataxis` reads to place a page; a project without one has a single audience, and that is the ordinary case rather than a gap. A project comes to have one at exactly two moments, neither of them here — the `/ai-diataxis-scaffold` audience question, asked once before any directory exists, and `/ai-diataxis` SURVEY offering it after a mode's contents list crosses seven items. **It is distinct from the `Audience` row above**, whose criterion is "Specifies who reads docs and what they know": that one calibrates register for `/ai-outline` and `/ai-draft` and selects no placement, while `## Audiences` selects placement and calibrates nothing. Add no frontmatter key for it and no Create Mode scaffold entry.
+
Rank gaps by impact:
1. **Objectives** — what everything else is validated against
2. **Constraints** — boundaries every skill respects; note that an existing bullet ending in `*(inferred)*` is a confirmation opportunity, not a gap
diff --git a/.claude/skills/ai-skills-reference/diataxis-classification.md b/.claude/skills/ai-skills-reference/diataxis-classification.md
index 2d7aaad..0914bbe 100644
--- a/.claude/skills/ai-skills-reference/diataxis-classification.md
+++ b/.claude/skills/ai-skills-reference/diataxis-classification.md
@@ -447,3 +447,253 @@ v1.6.0, MIT-0). One per mode:
When instantiating a template: keep `{fill-in slots}` as braces until filled,
strip every `{placeholder}` before the page is considered finished, and apply
the project's Voice, Key Terms, and Principles from `.context/README.md`.
+
+---
+
+## 12. The audience axis — this project's extension
+
+**Not Diátaxis doctrine, and optional.** Diátaxis supplies one axis, the four
+modes, and this project adds a second — **audience** — that a project *may*
+partition its docs tree by, with the four modes supplying the content axis inside
+it. The axis is declared by the project and never inferred by this toolchain. A
+project that declares nothing has one audience, and every rule in this section
+then selects nothing and prints nothing.
+
+### 12.1 The two parts of an audience
+
+An audience has two parts, and collapsing them is what makes the rule unusable.
+
+- Its **label** is the project's own word — what appears in a directory name, in
+ an index heading, and in what a builder types. A label is permitted to be
+ job-title-shaped: `user`, `developer`, `operator`.
+- Its **object** is the thing that audience's readers act on — *the released
+ command-line tool*, *this repository's source tree*, *a running instance of the
+ service*. **An object is never a person.**
+
+**The test keys on the object and returns the label.** That is the whole reason
+for the split. "Is this page for a user or a developer?" invites an opinion, and
+two readers of the same page answer it differently. "Does this page's reader act
+on the released tool, or on this repository's source tree?" has an answer you can
+point at in the page's own text.
+
+`user` and `developer` appear above as examples, and **an example is not a default.**
+No skill may fall back to them, offer them as the recommended answer
+outside an interview the builder is already in, or treat a project that declares
+neither as having those two. Enumerating a taxonomy on a project's behalf is what
+got the `ai-code-docs-user` and `ai-code-docs-dev` skills deleted.
+
+**Two sections share the word "audience" and nothing else.** The `### Audience`
+subsection under `## Documents` in `.context/README.md` calibrates *register* —
+who is reading and what they already know — for `/ai-outline` and `/ai-draft`,
+and it selects no placement; the top-level `## Audiences` section specified in
+12.2 selects *placement* and calibrates nothing. Both names have to appear
+together wherever either is described, because a maintainer who merges them
+leaves every project with one audience it cannot place by and no way to declare
+the other.
+
+### 12.2 The declaration
+
+A project declares its audiences in a top-level `## Audiences` body section of
+`.context/README.md`, read the way `## Key Terms` already is — prose bullets, not
+frontmatter, and not a per-page key. One bullet per audience, carrying the label,
+any aliases, the object its readers act on, and optionally its directory:
+
+```markdown
+## Audiences
+
+- **user** — acts on the released command-line tool; also "end user", "customer";
+ lives in `user-docs/`
+- **developer** — acts on this repository's source tree; also "contributor", "dev";
+ lives in `developer-docs/`
+```
+
+The section is **optional and never interviewed for.** `/ai-init` neither asks
+about it nor lists it as a gap. A project comes to have one at exactly two
+moments: the `/ai-diataxis-scaffold` audience question, asked once before any
+directory exists, and SURVEY offering it after a contents list crosses the
+threshold in 12.6.
+
+**No frontmatter key, and no per-page audience key.** The directory a page sits
+in, or the heading it is listed under, is the record. Where a page's audience has
+to be re-derived, the object test in 12.3 rank 5 derives it. A per-page key would
+be a second source of truth that nothing checks, and it would go stale the first
+time a page moved.
+
+### 12.3 Resolving which audience a request means — five ranks
+
+Work down the ranks and stop at the first that decides. **Every rank asks rather
+than guesses on zero matches and on two.** That posture is already this skill's
+rule for mode detection — "Never guess" at `ai-diataxis/SKILL.md:40-42` — and it
+matters more here, because a page filed under the wrong reader renders correctly,
+links correctly, and passes a site build.
+
+| Rank | Signal | Decides when | On failure |
+|---|---|---|---|
+| 1 | the request's own words | the phrase matches exactly one known label or alias | matches none or two → `AskUserQuestion` |
+| 2 | the `## Audiences` declaration | it supplies the labels, aliases, and objects the other ranks match against | absent → ranks 3 and 4 supply labels instead |
+| 3 | `dirname(docs_path)/CLAUDE.md`, read by path | it names the placement convention outright | absent or silent on placement → rank 4 |
+| 4 | the root detector (12.4) | at least one audience root exists on disk | none → the project has no audiences; place at the mode root and print nothing about the axis |
+| 5 | the object test | ranks 1–4 gave labels but none assigned *this* page | the page's object matches no declared object → report and ask; **never invent a label** |
+
+**Rank 1 matches by position, not by presence.** A label counts as a selector
+only in one of these positions:
+
+- `for the ` — "for the developer, document how to cut a release"
+- `-facing` — "a user-facing page on export formats"
+- `in docs` — "in developer docs, describe the build cache"
+- a leading ` docs:` — "user docs: how to reset a password"
+
+A label appearing anywhere else in the request is **content, not a selector.**
+Without the position rule, "explain how the cache serves the user" files a
+caching page as user-facing on the strength of a word describing the cache's
+behaviour.
+
+**Aliases are permitted; fuzzy matching is forbidden.** A project declaring
+`developer` will get "for devs", "for contributors", and "for maintainers" typed
+at it, so 12.2 carries an alias list and rank 1 matches against it. An unmatched
+word **asks** and names the declared labels. It does not resolve to the nearest
+one: a near-miss silently resolved is the one failure in this section with no
+detection at all, because the run reports a confident resolution and the page
+lands under the wrong reader.
+
+### 12.4 The audience-root detector
+
+A filesystem predicate, so it confers no discretion:
+
+```text
+A directory D directly under docs_path is an audience root when ALL FOUR hold:
+ 1. D is not itself one of tutorial, how-to, reference, explanation
+ 2. D is not output_path
+ 3. D does not begin with _
+ 4. D contains at least one subdirectory named tutorial/, how-to/,
+ reference/, or explanation/
+
+Mirror test (below-mode shape): /X/ is an audience root within that mode
+when X is not a mode name, X holds pages rather than further mode directories,
+AND at least one of these also holds:
+ a. X matches a declared audience label or alias (12.2), or
+ b. the same name X appears as a subdirectory of two or more mode directories
+
+An unpartitioned mode root is the implicit default audience.
+```
+
+Conditions 2 and 3 are the same two exclusions every walk in this skill already
+applies, for the same reason: `output_path` and underscore-prefixed directories
+hold unpublished files, so reading either as an audience root would partition the
+tree by an artifact of the build.
+
+**The mirror test needs (a) or (b) because a mode's subdirectory is more often a
+*topic* than an audience.** Reference in particular is supposed to have them —
+§2 requires reference to mirror the product's structure, and §7 names
+`reference/skills/` in this repository as that done right. Without the extra
+condition the mirror test reads those 17 pages as an audience partition, which
+would both invent an audience nobody declared and contradict §7 in the same pass.
+A genuine below-mode partition satisfies (b) by construction whenever it spans
+more than one mode, and satisfies (a) whenever the project has declared what it
+is doing; a topic subdirectory satisfies neither.
+
+Applied to this repository, the detector returns **zero** audience roots at every
+depth — `docs/docs` holds the four mode directories, `index.md`, and `working/`,
+which is `output_path`. The single-audience case is therefore a fact about the
+filesystem rather than an exemption a maintainer has to remember to honour.
+
+### 12.5 The four placements
+
+| Placement | Path | Who configures it | What reversing it costs |
+|---|---|---|---|
+| Above the modes | `//page.md` | the project: one sidebar entry and one navigation item per root | every page moves and every cross-mode index link breaks |
+| Below the modes | `//page.md` | nobody — an autogenerated per-mode sidebar picks the subdirectory up | pages move within one mode; cross-mode links resolve at the mode level and survive |
+| Beside the modes | `docs_path//page.md` | the project, as for a fifth mode | **rejected outright** — see below |
+| Nowhere in the tree | `docs_path//page.md` | nobody | nothing; the audience is addressed in the prose and selects no path |
+
+**Beside the modes is rejected, not merely discouraged.** A directory that holds
+pages directly and is not a mode gives those pages no mode, which abandons the
+single-mode discipline this whole reference exists to keep. An audience directory
+either contains mode directories (above) or sits inside one (below).
+
+**A skill never creates an audience root it cannot register.** An above-the-modes
+root is unreachable from a site's navigation until a sidebar entry and a
+navigation item exist for it, and no skill in this collection writes generator
+configuration. So a skill that creates such a root **prints the literal
+registration lines and states that the tree is unreachable until they are
+added** — pages that exist and no reader can reach is the failure most easily
+produced by being helpful. CREATE never creates an audience root at all: it
+places at the mode root and reports that the audience has no directory.
+
+### 12.6 The grouping threshold
+
+Source:
+(snapshot captured 2026-08-02, retrieved 2026-08-24).
+
+**Cite this page honestly or not at all.** The live URL
+`https://diataxis.fr/complex-hierarchies/` returns **404 as of 2026-08-24**, so
+the guidance is **published-but-withdrawn** rather than current. Quote the
+archived snapshot with its capture date, and never cite the bare canonical URL as
+though a reader could open it.
+
+**Seven items is the threshold, and it fires *above* seven rather than at it.**
+Eight items fire; seven do not. Write the test as `count > 7` and never
+`count >= 7`.
+
+An **item** is one entry in a directory's contents list — one per page or one per
+subdirectory — **excluding the directory's own index.** The count is
+non-recursive. Counting recursively would put this repository's `reference/` at
+37 and fire a grouping proposal on a tree already correctly grouped by pipeline
+role, which is the opposite of what the trigger is for.
+
+A freshly scaffolded tree holds zero pages per mode, so the trigger is
+arithmetically unable to fire there. That is how this rule satisfies the
+anti-scaffold prohibition in §6 — structurally, rather than by an exemption
+clause.
+
+**The two remedies the source demonstrates**, and only these two:
+
+1. **A landing-page prose grouping.** The mode's index gains headed groups and
+ lists each page under one of them. The directory is untouched and no page
+ moves, so no link breaks.
+2. **A nested directory inside one mode.** Pages move down one level within the
+ same mode, and the mode's own index links the subdirectory.
+
+**The source presents the parallel audience tree only as an unanswered
+question** — it raises the two-axis shape and declines to choose, asking "Which
+better? There seems to be a lot of repetition in either cases." **No skill may
+claim source support for that shape.** A project that adopts it does so on this
+project's argument, stated as this project's, exactly as §6 requires of the
+scaffold.
+
+### 12.7 The countable parts are mechanical
+
+The item count in 12.6, the four conditions in 12.4, and the four match positions
+in 12.3 are **mechanical and confer no discretion**, in the same sense §9's
+directory-index rule is. The tests are a count, a directory name, a subdirectory
+name, and a phrase position — never an agent's read of who a page "feels like it
+is for." An agent that finds itself reasoning about whether one of these rules
+applies has left the rule and is making the judgment call the ladder in 12.3
+routes to the user instead.
+
+---
+
+## 13. The four documentation roots
+
+"Developer documentation" names four different things in this repository, sitting
+under four different roots with four different publication statuses. The category
+is **retired rather than defined**, because no useful category spans two of these
+rows — a contributor's how-to page and a `plan.md` have nothing in common except
+the word.
+
+| Root | Declared in | Written by | Published? |
+|---|---|---|---|
+| The published docs tree | `docs_path` in `.context/README.md` | `/ai-diataxis`, `/ai-diataxis-scaffold` | yes — this is the site |
+| The working root | `output_path` in `.context/README.md` | `/ai-create`, `/ai-research`, `/ai-architect`, `/ai-plan`, `/ai-implement`, `/ai-outline`, `/ai-draft` | no — excluded from every walk, and gitignored here |
+| The decision log | `.adr-dir` at the repository root | `/ai-adr` | depends — a sibling of `docs_path` here, but nested inside it in most projects |
+| Agent context, by proximity | no declaring file; the file's own location is the declaration | `/ai-context`, and `/ai-diataxis-scaffold` for the docs-tree copy | no |
+
+**The audience axis applies to the first row only.** Rows 2, 3, and 4 are
+build-time artifacts and agent context; they are not addressed to a reader who
+came to the site, so partitioning them by audience would be a category error.
+
+**A request naming one of rows 2 through 4 is not a page request.** CREATE routes
+it to the skill that owns that root rather than classifying it — a `research.md`,
+an as-built write-up, a decision record, or a `CLAUDE.md` becomes a published
+page only if a step branches on path resolution alone and never asks which root
+the request belongs to.
diff --git a/.claude/skills/ai-skills-reference/framework-detection.md b/.claude/skills/ai-skills-reference/framework-detection.md
index f96b261..0bc97c8 100644
--- a/.claude/skills/ai-skills-reference/framework-detection.md
+++ b/.claude/skills/ai-skills-reference/framework-detection.md
@@ -1,10 +1,10 @@
# Framework Detection Reference
-Shared detection logic for documentation skills. Referenced by ai-diataxis (to detect the published docs root when `docs_path` is absent).
+Shared detection logic for documentation skills. Referenced by ai-diataxis for two jobs: detecting the published docs root when `docs_path` is absent, and finding the project's **placement convention** — where a new page goes once the root is known. The two are separate reads and the second is not conditional on the first: a project can carry `docs_path` in `.context/README.md` and still record its placement convention only in prose.
## Detection Priority
-1. **CLAUDE.md Context** (highest signal) — Check for doc paths, framework name, conventions, guidance file pointers
+1. **CLAUDE.md Context** (highest signal) — Check for doc paths, the placement convention (which directory a new page goes in, and whether the tree is partitioned by anything above or below the four modes), framework name, conventions, guidance file pointers. Read the file **by path** — `dirname(docs_path)/CLAUDE.md` and the project root's — rather than relying on it being in ambient context. In Kiro, read `.kiro/steering/*.md` as well: that runtime loads `.kiro/steering/`, not `CLAUDE.md`, so a rule assuming ambient presence resolves nothing there.
2. **Framework Detection** (filesystem probing) — Match config files to frameworks
3. **Heuristic Discovery** (fallback) — Look for common doc directories
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index a8d9af0..0000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,261 +0,0 @@
-# You can override the included template(s) by including variable overrides
-# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings
-# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure
-# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings
-# Container Scanning customization: https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings
-# Note that environment variables can be set in several places
-# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence
-
-# ASH security scanning component
-# Pinned to v3.0.5 — the internal `code.aws.dev` mirror has its own version
-# line (v3.0.x) that does not track upstream awslabs/automated-security-helper.
-# v3.0.5 (~2026-04-23) is the version against which our existing
-# `cyclonedx: enabled: false` suppression in .ash/ash.yaml was added (2026-04-21)
-# and verified working. v3.0.6 (~2026-05-16) appears to backport the upstream
-# regression where the new `gitlab-cyclonedx` reporter emits a minimal "empty"
-# SBOM that GitLab's parser rejects with "Required GitLab CycloneDX properties
-# are missing" — our existing suppression does not cover that new reporter.
-# Revisit when we wire up real GitLab dependency scanning, or when the mirror
-# exposes a setting to disable the gitlab-cyclonedx reporter.
-include:
- - component: code.aws.dev/proserve/automated-security-helper/automated-security-helper/ash@v3.0.5
- - component: code.aws.dev/proserve/genaiid/other/candidate-reusable-assets/code-quality/acq@develop
-
-# Any publicly available python image
-image: public.ecr.aws/docker/library/python:3.12-bookworm
-
-stages:
- - test
-# - securityScan # Inherited
- - deploy
- - release
-
-# AWS credential vendor: the internal code.aws.dev GitLab runner detects the
-# AWS_CREDS_TARGET_ROLE variable and vendors short-lived STS credentials for that
-# role into the job environment automatically — no id_tokens / assume-role block
-# needed. This job verifies the role assumption works before relying on it.
-# test:
-# stage: test
-# variables:
-# AWS_CREDS_TARGET_ROLE: arn:aws:iam::369530416671:role/ip-dev-sdlc-GitLab
-# AWS_DEFAULT_REGION: us-east-1
-# script:
-# - aws sts get-caller-identity
-# - aws s3 ls
-
-# GitLab Pages deployment job - must be named "pages" to trigger Pages deployment
-# Builds Docusaurus documentation and publishes to https://.gitlab.io//
-pages:
- stage: deploy
- needs: []
- image: public.ecr.aws/docker/library/node:22-bookworm
- variables:
- KUBERNETES_CPU_REQUEST: "2"
- KUBERNETES_CPU_LIMIT: "2"
- KUBERNETES_MEMORY_REQUEST: "4Gi"
- KUBERNETES_MEMORY_LIMIT: "4Gi"
- KUBERNETES_EPHEMERAL_STORAGE_REQUEST: "4Gi"
- KUBERNETES_EPHEMERAL_STORAGE_LIMIT: "4Gi"
- NODE_OPTIONS: "--max-old-space-size=3072"
- before_script:
- - corepack enable
- # Internal docs (developer-docs/internal/) DO publish to GitLab Pages —
- # this site is internal-only. They are filtered from the GitHub mirror
- # by internal/release/publish-github.sh EXCLUDE_PATHS, so they never reach
- # GitHub or GitHub Pages.
- - cd docs
- # Uses the pnpm version pinned in docs/package.json "packageManager"
- - pnpm install --frozen-lockfile
- script:
- - pnpm exec docusaurus build
- - mv build ../public
- artifacts:
- paths:
- - public
- rules:
- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
-
-# ---------------------------------------------------------------------------
-# Stage 1 — internal release. Two manual jobs, whose NAMES are the documentation.
-#
-# gitlab:release:preview manual. Prints the version that would be cut and the
-# exact notes that would be published. Creates NOTHING
-# — no tag, no branch, no Release object.
-# gitlab:release manual. Derives, asserts, tags origin, creates the
-# GitLab Release. This is the release.
-#
-# So: click preview, read the notes, click release. The preview is a voluntary
-# pre-flight, not a gate — nothing blocks a release on having run it.
-#
-# There is NO automatic tag-on-merge, no release branch, no release merge
-# request, and no separate job for a major version. Releasing is a deliberate
-# click. Both jobs call infra/scripts/release.sh, so the preview cannot report
-# notes that differ from what a release publishes, and the script's
-# absence-on-origin assertion means repeated clicks cannot double-tag.
-#
-# A MAJOR version bump is refused unless CONFIRM_VERSION equals the derived tag.
-# The guard is keyed on the version's SHAPE rather than on commit text, so it
-# holds after 1.0 as well — unlike cliff.toml's breaking_always_bump_major, which
-# is inert once the major reaches 1.
-#
-# NOTHING PUSHES A BRANCH. That is why no `git remote set-url --push` appears
-# below, and why the project setting Settings > CI/CD > Job token permissions >
-# "Allow Git push requests to this project's repository" is NOT required.
-#
-# The version is DERIVED from tags and commit history — there is no VERSION file.
-# `git-cliff --bumped-version` is the single source of truth.
-#
-# Publishing to the public GitHub mirror is stage 2 (`github:release`) and is
-# deliberately NOT part of any job here. Cutting an internal release publishes
-# nothing publicly. It also cannot be chained off a release: a CI-pushed tag
-# triggers no pipeline, so such a chain would silently never fire.
-#
-# Why these are manual jobs on a BRANCH pipeline rather than tag-triggered: a
-# tag pipeline cannot work here. `pages` requires $CI_COMMIT_BRANCH (unset on
-# tag pipelines) and the release jobs require $CI_COMMIT_TAG == null. A manual
-# job on a branch pipeline sidesteps both.
-# ---------------------------------------------------------------------------
-.release-base:
- stage: release
- image: public.ecr.aws/docker/library/python:3.12-bookworm
- variables:
- # Full clone required: version derivation walks history back to the newest
- # release tag, and tag operations need the tag objects themselves. A shallow
- # clone silently derives from the wrong base.
- GIT_DEPTH: "0"
- before_script:
- # Pinned to match the version every mechanism in this pipeline was verified
- # against. git-cliff is not in the base image.
- - pip install --quiet --disable-pip-version-check 'git-cliff==2.10.1'
- - git fetch origin --tags --force
- - git config user.email "ci@code.aws.dev"
- - git config user.name "GitLab CI"
-
-# The pre-flight. Prints the version that would be cut and the exact notes that
-# would be published, and creates nothing.
-#
-# Manual rather than automatic: with the changelog retired there is no merge
-# request whose diff shows the notes, so this is the place to read them — and a
-# job that ran on every push would be read on none of them.
-#
-# `allow_failure: true` keeps a failed preview from marking the pipeline failed.
-# It creates nothing, so a failure here is informational. `needs: []` so it is
-# reachable without waiting on the security scan, for the same reason.
-gitlab:release:preview:
- extends: .release-base
- rules:
- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TAG == null
- when: manual
- allow_failure: true
- - when: never
- needs: []
- script:
- - PREVIEW=1 bash infra/scripts/release.sh
-
-# THE RELEASE. Derives, asserts, tags origin, creates the GitLab Release.
-#
-# `allow_failure: false`, unlike the preview: this one creates something
-# irreversible, so a failure must be loud.
-#
-# `optional: true` on the ash dependency is required rather than cautious — ash
-# is contributed by an included component, and a hard `needs` on a job that may
-# not be in the graph makes the whole pipeline invalid instead of making the
-# release wait.
-#
-# Clicking it when there is nothing to release exits SUCCESSFULLY having created
-# nothing. That is deliberate: the button can be pressed speculatively, and a red
-# pipeline for "nothing to do" is a red pipeline everyone learns to ignore.
-gitlab:release:
- extends: .release-base
- rules:
- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TAG == null
- when: manual
- allow_failure: false
- - when: never
- needs:
- - job: ash
- optional: true
- script:
- - bash infra/scripts/release.sh
-
-# ---------------------------------------------------------------------------
-# Stage 2 — public publish. ONE job:
-#
-# github:release manual. Its FIRST run, with no CONFIRM_TAG, is the DRY
-# RUN: it prints TAG, FLOOR, the full notes body, and the
-# filtered-path report, publishes nothing, and prints the
-# exact CONFIRM_TAG value to re-run with. Re-running it
-# with that value publishes — the one irreversible act in
-# the release path, since it force-pushes a public
-# repository that has accepted pull requests.
-#
-# So clicking this job never publishes by accident. The confirmation gate is
-# unchanged; only the dry-run TRIGGER moved, from a separately named job to the
-# absence of CONFIRM_TAG. A missing confirmation already expressed that intent,
-# so the second job was a second way to say the same thing.
-#
-# Publishing is deliberately separate from cutting an internal release, so an
-# internal release can be cut without going public and an OLDER tag can be
-# published later (set TAG). The floor — what GitHub already has — is resolved
-# from GitHub itself, so notes cover every change since the last publish even
-# when several internal releases were skipped.
-#
-# Publishing withholds only TAGS and RELEASE OBJECTS, never code: the mirror
-# amends the tip and force-pushes main, so a skipped release's code lands
-# publicly anyway. That is what makes cumulative notes correctness rather than
-# courtesy.
-#
-# It cannot be chained off gitlab:release: a CI-pushed tag triggers no pipeline,
-# so such a chain would silently never fire.
-# ---------------------------------------------------------------------------
-.publish-base:
- stage: release
- image: public.ecr.aws/docker/library/python:3.12-bookworm
- variables:
- # Full clone required on two counts: the publish path unshallows before
- # reasoning about ancestry (a truncated graph answers "not an ancestor" for
- # commits that are), and git's pack negotiation otherwise references objects
- # past the shallow boundary, which GitHub rejects.
- GIT_DEPTH: "0"
- rules:
- - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TAG == null
- when: manual
- allow_failure: false
- - when: never
- before_script:
- - pip install --quiet --disable-pip-version-check 'git-cliff==2.10.1'
- # $GITHUB_DEPLOY_KEY is base64-encoded (masked + protected CI variable).
- # It lives on the publish jobs only — stage 1 never touches GitHub.
- - apt-get update -qq && apt-get install -y -qq openssh-client
- - eval $(ssh-agent -s)
- - |
- # 350 bytes, not 200: the smallest key type we would ever use is an
- # OpenSSH ed25519 private key, which measures 387 bytes — so a 200-byte
- # floor accepts a key truncated to roughly half its length. The threshold
- # exists to catch a mis-pasted or clipped CI variable before ssh-add
- # fails with something less obvious, so it has to sit just under the
- # smallest legitimate key rather than far below it.
- DECODED_LEN=$(printf '%s' "$GITHUB_DEPLOY_KEY" | base64 -di 2>/dev/null | wc -c || true)
- if [ "${DECODED_LEN:-0}" -lt 350 ]; then
- echo "ERROR: GITHUB_DEPLOY_KEY decodes to only ${DECODED_LEN:-0} bytes." >&2
- echo "hint: expected >= 350 (an ed25519 private key is ~387 bytes, RSA-2048" >&2
- echo " ~1800). A short value usually means the variable was truncated" >&2
- echo " when pasted, or holds a PUBLIC key rather than the private one." >&2
- exit 1
- fi
- - printf '%s' "$GITHUB_DEPLOY_KEY" | base64 -di | ssh-add -
- - mkdir -p ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
- - git fetch origin --tags --force
-
-# The publish. NO CONFIRM_TAG means dry run — run it once to read the report and
-# learn the value, then re-run with CONFIRM_TAG set to publish.
-#
-# Set TAG to publish something other than the newest internal tag; leave it unset
-# for the newest. `--dry-run` is deliberately NOT passed: omitting CONFIRM_TAG is
-# what makes this a dry run, and passing the flag as well would make the
-# confirmation unreachable from CI.
-github:release:
- extends: .publish-base
- needs: []
- script:
- - bash internal/release/publish-github.sh ${TAG:-}
diff --git a/.gitlab/merge_request_templates/default.md b/.gitlab/merge_request_templates/default.md
deleted file mode 100644
index cb483c5..0000000
--- a/.gitlab/merge_request_templates/default.md
+++ /dev/null
@@ -1,14 +0,0 @@
-## Summary
-
-
-
-## Release Notes
-
-
-
-## Checklist
-
-- [ ] Commits follow Conventional Commits (drives version derivation and release notes)
-- [ ] CHANGELOG.md updated (if releasing)
-- [ ] Tests pass
-- [ ] No secrets in committed files
diff --git a/app-lib/uv.lock b/app-lib/uv.lock
index 975a17b..c395c86 100644
--- a/app-lib/uv.lock
+++ b/app-lib/uv.lock
@@ -35,15 +35,15 @@ wheels = [
[[package]]
name = "anyio"
-version = "4.13.0"
+version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
diff --git a/docs/docs/developer-docs/internal/CLAUDE.md b/docs/docs/developer-docs/internal/CLAUDE.md
deleted file mode 100644
index 76f72f4..0000000
--- a/docs/docs/developer-docs/internal/CLAUDE.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# developer-docs/internal/
-
-Internal-only docs. Committed to GitLab; filtered from GitHub release by `internal/release/publish-github.sh` (path is in `EXCLUDE_PATHS`).
-
-- Use this section for material that should stay inside Amazon — internal release runbooks, ops notes, links to Brazil/Apollo/Pipelines/Taskei, etc.
-- Sidebar autogenerates from disk. The section is visible whenever this directory exists; filtering it out at release time hides it from the GitHub docs site without code changes.
-- Do **not** link to `internal/` pages from any public-facing page (anything outside `internal/`). Those links would 404 on the published GitHub site.
-- Front matter conventions match the rest of `developer-docs/`: `title` required, `sidebar_position` for ordering, `title: Overview` on category index pages.
-- To remove the entire section locally, delete the directory — Docusaurus regenerates the sidebar without it.
diff --git a/docs/docs/developer-docs/internal/exploration/index.md b/docs/docs/developer-docs/internal/exploration/index.md
deleted file mode 100644
index 6ea7811..0000000
--- a/docs/docs/developer-docs/internal/exploration/index.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-title: Overview
-sidebar_label: Exploration
-sidebar_position: 1
----
-
-# Exploration
-
-Exploratory and direction-setting documents for the IPA project. Content here
-captures investigations, product-design proposals, and convergence studies that
-inform future direction but are not yet committed plans or implementation specs.
-
-Like the rest of `internal/`, this section is committed to GitLab but filtered
-out before publishing to GitHub. It is appropriate for material that should stay
-inside Amazon while a direction is still being socialized.
-
-- **[LaunchBridge / IPA Convergence](./launchbridge-ipa-convergence.md)** — A
- proposal to merge the Innovation Patterns Agent into the LaunchBridge product
- under one brand, with a forward-looking plugin, installer, and bring-your-own
- spec-driven-development approach.
diff --git a/docs/docs/developer-docs/internal/exploration/launchbridge-ipa-convergence.md b/docs/docs/developer-docs/internal/exploration/launchbridge-ipa-convergence.md
deleted file mode 100644
index ab79803..0000000
--- a/docs/docs/developer-docs/internal/exploration/launchbridge-ipa-convergence.md
+++ /dev/null
@@ -1,299 +0,0 @@
----
-title: LaunchBridge / IPA Convergence
-sidebar_position: 2
----
-
-# LaunchBridge / IPA Convergence
-
-## Executive Summary
-
-The Innovation Patterns Agent (IPA) and LaunchBridge are two suites of Claude
-Code skills — collections of instruction documents that Claude Code activates on
-demand to perform a defined task. The two suites have grown adjacent but
-separate. This document proposes merging IPA into LaunchBridge under a single
-brand, and lays out a forward-looking approach for doing so.
-
-The headline finding is that the two products are complementary, not
-overlapping. LaunchBridge owns the application and evaluation layer — indexing,
-governance, testing, prediction, metrics, and shipping — together with a mature,
-multi-agent installer. IPA owns the infrastructure layer — composing and
-deploying full-stack AWS environments — which LaunchBridge lacks entirely. The
-merge direction follows directly from that asymmetry: LaunchBridge is the
-chassis, and IPA is the missing engine.
-
-Three problems make the merge non-trivial, and this document treats each
-honestly rather than glossing over them: branding and command namespacing,
-startup context budget across a large skill catalog, and avoiding collisions
-between similarly named skills. All three are solvable with the Claude Code
-plugin mechanism and the installer LaunchBridge already ships.
-
-This is a direction-setting proposal. It makes the case for convergence,
-describes the architecture at a high level, and surfaces the decisions that need
-alignment. The implementation specification lives separately.
-
-The three points to carry forward:
-
-- **The suites are complementary.** Their capabilities barely overlap, so
- convergence is mostly additive — it adds capability rather than forcing
- features to be reconciled.
-- **Package the result as a Claude Code plugin.** A plugin delivers command
- namespacing, one-step installation, and the ability to pay context cost only
- for the domains a customer enables.
-- **Keep the supporting commitments lightweight.** Bring-your-own
- spec-driven-development and the installer technology choice are best handled
- with the smallest durable solution, not new machinery.
-
-## Why Converge
-
-IPA and LaunchBridge today differ in how their skills are invoked, and they
-share no common distribution path. IPA skills are invoked by command name; many
-LaunchBridge skills are triggered by keyword matches against their descriptions.
-Keeping the two suites separate carries an ongoing cost: duplicated effort, the
-risk of version drift on the assets they already share, and an incoherent story
-for any customer who wants both infrastructure and evaluation capabilities in one
-project.
-
-Each suite has a single defining gap that the other closes. IPA can compose and
-deploy infrastructure, but it has no installer — its skills are placed by
-manually copying files into a project. LaunchBridge has a mature, multi-agent
-installer with incremental updates, but it deploys no infrastructure of its own.
-A merge closes both gaps at once. Because the two capability sets barely
-intersect, the merge is mostly additive: the friction lies in naming, context
-budget, and brand, not in conflicting features that must be reconciled.
-
-### What Each Side Brings
-
-The combined capability map shows near-zero overlap across every domain. Where a
-surface looks like a collision, it resolves on inspection into two skills
-operating at different altitudes.
-
-| Domain | IPA | LaunchBridge | Relationship |
-|---|---|---|---|
-| Infrastructure compose and deploy | Yes — multiple stacks, generated Makefiles, CloudFormation and Terraform | None | IPA fills LaunchBridge's largest gap |
-| Security | IAM role and permission provisioning | Static application security scanning | Complementary; different domains |
-| CI/CD | Deploys AWS-native pipeline infrastructure | Scaffolds provider-agnostic CI configuration | Reconcilable; different altitude |
-| Indexing and Q&A | None | Indexing and question-answering | LaunchBridge only |
-| Test, evaluation, and metrics | None | Several evaluation and metrics skills | LaunchBridge only |
-| Configuration and prompt registry | Deployment parameters | Application configuration registry | Different namespaces |
-| Spec-driven development workflow | References it conceptually | Vendors and routes a shared workflow | Shared dependency |
-| Multi-agent installer | None | Mature | LaunchBridge is the merge vehicle |
-
-Two apparent collisions deserve a one-line resolution each. IPA's security skill
-provisions IAM roles and permission boundaries, while LaunchBridge's security
-skill aggregates static-analysis scanners — these are different problems, and a
-converged suite keeps both. IPA's pipeline skill deploys an AWS-native pipeline
-as infrastructure, while LaunchBridge's shipping skill scaffolds
-provider-agnostic continuous-integration configuration — the same problem space
-at different altitudes, reconcilable rather than conflicting.
-
-There is exactly one true shared dependency: the family of skills that drives the
-shared spec-driven-development and document workflow. Today it is referenced by
-IPA and vendored by LaunchBridge. The merge should own it once, not twice.
-
-## The Convergence Architecture
-
-The merge rests on four forward-looking design decisions, each addressing one of
-the hard problems named in the summary: how commands are namespaced, how context
-cost is controlled at scale, how skills are distributed, and how the suite
-accommodates whatever spec-driven-development framework a customer already uses.
-Each is taken in turn below.
-
-### Namespacing via a Claude Code Plugin
-
-In Claude Code, a skill's invocation name comes from its directory name, and the
-only way to produce a colon-separated command namespace is through a **plugin** —
-a packaged bundle of skills (and optionally agents, hooks, and tool
-configuration) that installs in one step. A plugin automatically prefixes every
-command it contains with the plugin's name. Packaging the converged suite as a
-plugin named `lb` therefore yields commands of the form `/lb:deploy`
-automatically. Without a plugin, the achievable form is a flat command prefix
-such as `/lb-deploy`, where the prefix is simply part of each skill's name.
-
-Plugins are the right vehicle for reasons beyond the namespace. They install in a
-single step, they carry a version, and they can be split into **sub-plugins** —
-smaller plugins grouped by domain — so that a customer pays the startup context
-cost only for the groups they enable. Whether the suite ships as one plugin or as
-several domain sub-plugins is an open decision; the recommendation is deferred to
-"Decisions to Align On."
-
-### Context-Efficient, Just-in-Time Activation
-
-Claude Code loads skills through **progressive disclosure**: at the start of a
-session, only each skill's name and short description are loaded; the full
-instruction body loads only when the skill is triggered or invoked. The
-session-start cost is therefore small per skill — on the order of a few hundred
-tokens of metadata — but it accumulates. A catalog approaching forty skills
-costs a few thousand tokens at startup, measured against a listing budget that
-defaults to a small fraction of the model's context window. When that budget is
-exceeded, descriptions are shortened, which degrades the keyword matching that
-auto-triggers skills.
-
-The honest constraint is that Claude Code provides no native, path-conditional
-skill gate. The intuitive goal of "show infrastructure skills only when the user
-is working on infrastructure" has no first-class mechanism. The achievable
-strategy is to group skills into domain sub-plugins, write precise descriptions
-so triggering stays accurate, and — if needed — use session hooks to suggest
-relevant skills. These numbers are guidance rather than hard limits; the right
-move before committing to a single-plugin layout is to measure a real install
-with Claude Code's diagnostics and confirm that no descriptions are being
-truncated under budget pressure.
-
-### The Installer as the Merge Vehicle
-
-LaunchBridge's installer already solves the distribution problems IPA never
-began. It maintains a registry of target agents and writes each agent's skills to
-the correct location, so a single run can install for more than one agent. It
-chooses between copying and symlinking depending on whether the install is for
-active development or for delivery. It tracks a content hash per skill so that an
-update re-copies only the skills that actually changed. It groups skills into
-selectable bundles and supports non-interactive flags, which is precisely the
-seam that makes "install some, not all" real today — at the granularity of a
-single skill. It also exposes a provider hook for sourcing external skills, which
-is the natural place for IPA's skills to plug in. Notably, the installer is the
-most thoroughly tested asset across both repositories, which is itself an
-argument for building the merge around it.
-
-There is one open technology question. The installer is implemented in
-JavaScript today. A Python-based install path — invoked as a tool installed
-directly from a git repository — is a proven model for this class of tooling and
-is attractive for ecosystem consistency. But it is a reimplementation of the
-installer, not a thin wrapper: it would have to reproduce the agent registry, the
-copy-and-symlink logic, content-hash diffing, and submodule resolution. The
-existing installer already supports installation from a git repository and
-incremental updates, so this is a strategic preference to cost out deliberately,
-not an urgent capability gap.
-
-### Bring Your Own SDD (BYOSDD)
-
-**Spec-driven development (SDD)** is the practice of capturing a feature's intent
-in structured specification files before implementing it, and several frameworks
-exist for doing so. The requirement here is simple: a customer can plug in
-whichever SDD framework they already use, or adopt a suggested default —
-**openspec**, a lightweight, portable, open-source SDD framework.
-
-The lightweight answer is that BYOSDD is a stance, not a piece of machinery, and
-it rests on three inexpensive commitments. First, zero coupling: no converged
-skill requires specification files to exist, so a customer using any framework —
-or none — works without configuration. This property already holds today.
-Second, a single optional setup question: during initialization, the suite asks
-once whether to set up SDD, offering openspec as the recommended default, the
-customer's own framework, or nothing — and acts only if openspec is chosen.
-Third, a short note in the project's agent-context file telling the agent to
-honor whatever specification artifacts are present by reading them as context.
-
-The proof that this is sufficient is the repository itself: it already runs two
-SDD dialects side by side with no adapter between them. That coexistence is the
-strongest available evidence that the requirement needs a stance and a default,
-not a routing layer.
-
-## Decisions to Align On
-
-These are the choices that need stakeholder buy-in. Each is listed with the
-recommended option and a one-line rationale; the supporting argument lives in the
-architecture sections above. The intent is to let readers agree quickly or
-redirect deliberately.
-
-| Decision | Recommended option | Rationale |
-|---|---|---|
-| Merge direction | Fold IPA into LaunchBridge | LaunchBridge has the installer, the multi-agent abstraction, and the test coverage; IPA has the missing infrastructure capability. |
-| Plugin packaging | Multiple domain sub-plugins | Makes context cost opt-in by domain while still delivering the namespace. |
-| BYOSDD shape | Stance plus optional openspec default | A routing manifest has no consumer today; zero coupling already works. |
-| Just-in-time strategy | Sub-plugins plus precise descriptions | There is no native path gate; this is the achievable approximation. |
-| Installer technology | Keep the current installer; defer the rewrite | It already does git-repo install and incremental updates; the rewrite is a preference. |
-| Model coupling | Keep converged skills model-agnostic | Pinning a specific model undercuts the suite's multi-agent portability. |
-| Shared-workflow ownership | Single source through the installer | Three owners of one asset invites version skew; consolidate to one. |
-| First milestone | The plugin and namespace spike | Cheapest way to prove the namespace and measure context cost before committing. |
-
-## The Path Forward
-
-The path is phased so that the early, reversible work proves the foundations
-before any commitment to brand or distribution model. The first two phases are
-low-risk and can be undone; the later phases commit to the rebrand and the
-distribution choice. Throughout, the Makefiles already delivered to customers
-remain untouched — they carry no IPA-specific branding and depend on no IPA
-tooling, so customer deployments are unaffected by the merge.
-
-| Phase | Goal | Reversible |
-|---|---|---|
-| 1. Plugin and namespace spike | Prove the `/lb:` namespace and measure startup context cost across a representative skill subset. | Yes |
-| 2. BYOSDD stance and default | Guarantee zero SDD coupling, add the one optional setup question, and add the agent-context note. | Yes |
-| 3. Installer integration | Register IPA's infrastructure skills as a selectable group in the LaunchBridge installer. | Yes |
-| 4. Rebrand | Replace IPA branding with LaunchBridge across skills, context, and documentation. | Largely |
-| 5. Alternative install path (optional) | Provide a Python-based install path if the cost spike justifies it. | N/A |
-
-The sequencing logic is deliberate. Phase 1 answers the single most consequential
-architectural question — whether to ship one plugin or several — with measured
-token numbers rather than estimates, and it does so before any brand work begins.
-Phases 2 and 3 deliver customer-visible value (a clean SDD story and "install
-some, not all") while remaining reversible. Only Phase 4 commits to the new
-identity, and it is sequenced last among the required phases precisely so that the
-foundations are proven first. Phase 5 is optional and gated on its own cost
-assessment.
-
-## What We Are Not Building (Yet)
-
-A short, deliberate statement of what is out of scope keeps the proposal honest
-and pre-empts over-engineering. Three items are explicitly deferred.
-
-- **A structured routing layer for SDD frameworks.** No converged skill needs to
- read specification files uniformly across frameworks today, and the SDD tools
- drive themselves. Such a layer would add a maintenance surface that must track
- every framework's evolving commands, with no consumer to justify it. It should
- be built only if a concrete skill later needs uniform cross-framework access to
- specification artifacts.
-- **The Python-based installer rewrite.** Deferred until a customer needs a
- Python-only install path. The existing installer already supports git-repo
- installation and incremental updates, so this is a preference rather than a
- gap.
-- **Custom path-conditional activation hooks.** Building machinery to show
- infrastructure skills only inside infrastructure directories is speculative
- effort with no native support. Domain sub-plugins and precise descriptions
- cover the need without it.
-
-## Risks and Open Questions
-
-A proposal earns trust by naming risk plainly. The following are the substantive
-risks the convergence carries, followed by the assumptions that remain
-unvalidated.
-
-**Risks.**
-
-- **Namespace mechanics constrain the command form.** A true colon namespace
- requires packaging as a plugin; without one, the suite is limited to a flat
- command prefix. This decision shapes everything downstream and should be made
- early.
-- **Context budget at scale.** A combined catalog approaching forty skills can
- trip the startup listing budget, shortening descriptions and degrading
- auto-triggering. Splitting into domain sub-plugins is the mitigation; there is
- no native path-conditional gate to fall back on.
-- **Model-coupling divergence.** IPA pins a specific model; LaunchBridge stays
- model-agnostic for portability. A merge must choose, and pinning a model leaks
- Claude-specific assumptions into a suite that aims to be agent-agnostic.
-- **Installer rewrite cost.** A Python-based install path is a real project, not
- a wrapper, and its effort is currently unestimated. It needs its own cost spike
- before any commitment.
-- **Brand and identity churn.** Retiring the IPA brand means rewriting context
- files, skill descriptions, and documentation. The work is real and must be
- sequenced so that delivered customer Makefiles remain unaffected.
-- **Shared-workflow ownership.** The shared SDD-and-document workflow currently
- has three owners. The merge must pick a single distribution path or risk
- version skew.
-
-**Unvalidated assumptions.** Two assumptions underlie the proposal and are
-product strategy rather than verified findings: that customers want multi-agent
-support, and that customers want bring-your-own-SDD. Both should be confirmed
-with stakeholders rather than treated as settled. The token and budget figures
-cited throughout are documented guidance, not hard limits, and should be measured
-on a real install before they drive a final layout decision.
-
-## Recommendation
-
-Convergence is the right direction: the capabilities are complementary, the merge
-is additive, and LaunchBridge already provides the distribution machinery IPA
-needs. The recommended first step is the lowest-risk one — a plugin and namespace
-spike that proves the `/lb:` command form and measures real context cost — so
-that the most consequential decision, single plugin versus domain sub-plugins,
-rests on evidence. The supporting commitments around SDD and installer technology
-should stay deliberately lightweight until a concrete need justifies more. With
-alignment on the decisions listed above, the phased path can begin without
-disturbing any customer already running delivered infrastructure.
diff --git a/docs/docs/developer-docs/internal/index.md b/docs/docs/developer-docs/internal/index.md
deleted file mode 100644
index 33ed0d0..0000000
--- a/docs/docs/developer-docs/internal/index.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-title: Overview
-sidebar_label: Internal
-sidebar_position: 99
----
-
-# Internal
-
-Internal-only documentation for the IPA project. Content here is committed to GitLab but **filtered out before publishing to GitHub** by `internal/release/publish-github.sh`.
-
-This section is for material that is appropriate to share with internal Amazon contributors but should not appear on the public `aws-samples/sample-innovation-patterns` repo or its docs site — for example, internal release runbooks, environment-specific operational notes, or links to internal-only systems (Brazil, Apollo, Pipelines, Taskei).
-
-## Conventions
-
-- Each topic gets its own subdirectory or `.md` file. Front matter follows the standard developer-docs conventions (`title`, `sidebar_position`).
-- Do **not** reference `internal/` content from public pages — those links would 404 on the GitHub site.
-- The whole `internal/` directory is in `EXCLUDE_PATHS` in `internal/release/publish-github.sh`, so adding files here automatically inherits the filter.
-
-## Visibility
-
-The Docusaurus sidebar is autogenerated, so this section appears in the left nav whenever the `internal/` directory exists on disk. Removing the directory (or filtering it out, as the GitHub release does) hides it without any config change.
-
-## Adding Content
-
-1. Create a new `.md` file or subdirectory under `internal/`.
-2. Add `title` and (optionally) `sidebar_position` front matter.
-3. If you create a subdirectory, add an `index.md` with `title: Overview` so the category page renders cleanly.
-4. Verify the page renders locally (`npm start` from `docs/`) before committing.
diff --git a/docs/docs/developer-docs/internal/operations/index.md b/docs/docs/developer-docs/internal/operations/index.md
deleted file mode 100644
index 66b9cbb..0000000
--- a/docs/docs/developer-docs/internal/operations/index.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-title: Overview
-sidebar_label: Operations
-sidebar_position: 1
----
-
-# Operations
-
-Operational documentation for the IPA project, including release runbooks and environment management procedures.
-
-- **[Runbooks](runbooks)** — Step-by-step procedures for operational tasks
diff --git a/docs/docs/developer-docs/internal/operations/runbooks/index.md b/docs/docs/developer-docs/internal/operations/runbooks/index.md
deleted file mode 100644
index 7ec3164..0000000
--- a/docs/docs/developer-docs/internal/operations/runbooks/index.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-title: Overview
-sidebar_label: Runbooks
-sidebar_position: 1
----
-
-# Runbooks
-
-Operational runbooks for recurring IPA procedures.
-
-- **[Releasing](releasing.md)** — How to cut a release: preview the derived version and notes, click one job to tag and publish internally, and optionally publish to the public GitHub mirror as a separate act
diff --git a/docs/docs/developer-docs/internal/operations/runbooks/releasing.md b/docs/docs/developer-docs/internal/operations/runbooks/releasing.md
deleted file mode 100644
index 9070397..0000000
--- a/docs/docs/developer-docs/internal/operations/runbooks/releasing.md
+++ /dev/null
@@ -1,372 +0,0 @@
----
-title: Releasing
-sidebar_position: 10
----
-
-# Releasing
-
-How to cut a release of the IPA framework. The version is **derived** from Conventional Commit history — there is nothing to type and no file to bump. Release notes live on the annotated tag and the GitLab Release object; there is no committed changelog.
-
-## TL;DR
-
-```
-1. Click "gitlab:release:preview" on the pipeline for main.
- -> prints the version that would be cut and the exact notes. Creates nothing.
-
-2. Click "gitlab:release".
- -> tags origin and creates the GitLab Release
-
-3. (Optional, separate) Click "github:release" to publish to the public mirror.
- -> the FIRST click is a dry run. Re-run it with the CONFIRM_TAG it prints.
-```
-
-Step 1 is a **voluntary pre-flight, not a gate** — nothing stops you clicking `gitlab:release` without it. There is no pre-tag review of the notes; see [The trade this flow makes](#the-trade-this-flow-makes).
-
-Publishing to the public GitHub mirror is a **separate** act that is not part of cutting an internal release. See [Publishing to GitHub](#publishing-to-github).
-
-## Prerequisites
-
-- Push access to `main` on GitLab.
-- `GITHUB_DEPLOY_KEY` as a masked, protected CI variable — **only** for publishing. No internal release job touches it. See [GITHUB_DEPLOY_KEY](#github_deploy_key).
-- **`git-cliff`** installed locally, only if you want to preview from a workstation — `brew install git-cliff`. CI installs its own pinned copy.
-
-:::note No project setting is required
-Nothing in the release path pushes a **branch** any more, so the *Allow Git push requests to this project's repository* setting (Settings → CI/CD → Job token permissions) is **not** needed, and no `release/*` branch has to be left unprotected. The only ref any release job writes is the tag.
-:::
-
-## The Jobs
-
-The job names are the documentation. All three run on a **branch** pipeline for `main`, and all three are **manual**.
-
-| Job | What it does | What it creates | What it does NOT create |
-|-----|--------------|-----------------|--------------------------|
-| `gitlab:release:preview` | Prints the current tag, the version that would be cut, and the exact notes body | Nothing | No tag, no branch, no Release object |
-| `gitlab:release` | Derives, asserts, tags `origin`, creates the GitLab Release | An annotated tag and a GitLab Release | Nothing on GitHub |
-| `github:release` | Publishes a chosen tag to the public mirror with cumulative notes. **First run is a dry run** | With `CONFIRM_TAG`: a filtered public tree, a public tag, and a GitHub Release | Without `CONFIRM_TAG`: nothing at all |
-
-Both `gitlab:*` jobs call `infra/scripts/release.sh`, so the preview cannot report notes that differ from what a release publishes. Its absence-on-origin assertion means repeated clicks cannot double-tag.
-
-`gitlab:release:preview` is `allow_failure: true` — it creates nothing, so a failure is informational. `gitlab:release` is not: it creates something irreversible, so a failure is loud.
-
-Clicking `gitlab:release` when there is nothing to release exits **successfully** having created nothing — see [the troubleshooting entry](#nothing-happened).
-
-## Procedure: Cutting a Release
-
-### Step 1: Land your work
-
-Commit with Conventional Commit messages and push to `main`. The message format is what determines the version and the notes — a non-conventional commit derives no bump and appears in no section.
-
-### Step 2: Click gitlab:release:preview
-
-Read the notes it prints. Ask:
-
-- Are the entries accurate?
-- Is the derived version right? A `feat:` gives a minor bump, a `fix:` a patch.
-- Is anything missing? Work committed with a non-conventional subject is invisible here.
-
-To fix wording, amend the commit body — prose lives in commit bodies, which `cliff.toml` renders as indented continuation text under each bullet. To add a missing entry, add an empty commit carrying the prose:
-
-```bash
-git commit --allow-empty -m 'feat(scope): subject' -m 'body...'
-```
-
-### Step 3: Click gitlab:release
-
-It derives, runs every assertion **before** creating anything, then tags `origin` and creates the GitLab Release.
-
-### Step 4: Verify
-
-- [ ] The tag exists on GitLab
-- [ ] A GitLab **Release object** exists for that tag, not just a bare tag
-- [ ] The Release body carries the generated notes with `## [` headings intact
-- [ ] Nothing changed on GitHub — publishing is separate
-
-## Procedure: Hotfix on Main
-
-1. Create a short-lived feature branch from `main`:
- ```bash
- git checkout -b fix/critical-bug main
- ```
-2. Make the fix, commit with a `fix:` prefix
-3. Open an MR targeting `main`, get review, merge
-4. Follow the standard release procedure above — the `fix:` commit derives a patch bump
-
-## Publishing to GitHub
-
-Cutting an internal release and publishing to the public mirror are **separate acts**. Nothing in the internal flow pushes anything public.
-
-There is one job, and **its first run publishes nothing**:
-
-| Run | Effect |
-|-----|--------|
-| `github:release` with no `CONFIRM_TAG` | Prints `TAG`, `FLOOR`, the full notes body, and the filtered-path report. Publishes nothing. Ends with `hint: re-run with CONFIRM_TAG=` |
-| `github:release` with `CONFIRM_TAG=` | Publishes. The one irreversible act in the release path — it force-pushes a public repository that has accepted pull requests |
-
-:::note Publication cannot be automated off a release
-A tag pushed by CI triggers **no pipeline** — GitLab documents this explicitly for job-token pushes. Chaining publication off the release tag would therefore fail silently: the tag appears, nothing happens, and no error is reported anywhere. Publication stays a deliberate manual act because that is the only shape that works.
-:::
-
-### Procedure
-
-1. Run `github:release`. Set the `TAG` variable to publish something other than the newest internal tag; leave it unset for the newest.
-2. Read its output — the notes body is exactly what the public Release will carry.
-3. Re-run `github:release` with `CONFIRM_TAG` set to the value it printed.
-
-### Publishing an older tag
-
-Supply `TAG=v0.3.0`. Nothing is retagged internally: the internal tag already exists and is correct, and only the public side is brought forward. The operator's local tags are byte-identical before and after a publish run — the publish path writes no local ref at all.
-
-### What a gap in the public tag sequence means
-
-The public tag sequence is allowed to be gapped — `v0.1.7` then `v0.4.0` with nothing between — and it carries a specific meaning worth being precise about.
-
-**Publishing does not withhold code.** The publish path amends the tip and force-pushes `main`, so the code of a release you never published reaches the public repository the next time you publish anything. What is withheld is the **tag and the Release object**.
-
-So a gap describes real, already-public code that no Release object documents. That is why the notes for a publish cover everything since the last publish rather than just the newest version: the notes for `v0.4.0` published over a floor of `v0.1.7` contain a section per intervening version, so nothing that shipped goes undescribed.
-
-The floor is resolved from GitHub itself — GitHub is the only authority on what GitHub already has, so there is no "last published" record to drift.
-
-For the mechanisms and their hazards, see [`internal/release/README.md`](https://code.aws.dev/proserve/genaiid/other/candidate-reusable-assets/innovation-patterns/-/blob/main/internal/release/README.md).
-
-## The trade this flow makes
-
-**There is no pre-tag review of the release notes.** Earlier the notes reached `main` as a committed `CHANGELOG.md` on a release branch behind a merge request, and that diff was the review gate. Retiring the committed changelog retired the gate with it.
-
-What that gate was added for cannot recur: at `v0.2.0` the committed changelog still read `## [Unreleased]` while the tag and the Release both said 0.2.0. A file that does not exist cannot go stale relative to its tag, so the incident class is structurally impossible now rather than merely guarded against.
-
-What is genuinely lost is a *blocking* look at the notes before a tag exists. `gitlab:release:preview` replaces it with a voluntary one.
-
-**Commit-message adherence is now the single point of failure.** With no changelog to review, a non-conventional commit is invisible: it derives no bump and appears in no notes section, and there is no artifact in which anyone would notice. Adherence has been measured at 27%, 17%, and 33% in past samples. There is no commitlint job — the convention is advisory, and this is the risk that carries.
-
-## Troubleshooting
-
-### I clicked gitlab:release and nothing happened {#nothing-happened}
-
-The job went **green** and created nothing. That is the no-op, and it is the most likely confusing outcome — a green job is easier to miss than a red one. Read the log: it names the tag it compared against.
-
-The usual cause is that every commit since the last tag is `chore:`, `test:`, `style:`, or `Update:`-shaped, all of which are configured to derive no bump.
-
-**Fix:** if the work deserves a release, it deserves a conventional commit message. Nothing is wrong with the pipeline.
-
-### I clicked github:release and it published nothing
-
-That is the dry run, and it is what the first click always does. The log ends with the exact value to re-run with:
-
-```
-hint: re-run with CONFIRM_TAG=v0.2.1
-```
-
-Re-run the job with that variable set. Everything above that line in the log is what a real publish would do.
-
-### The release fails: local tag disagrees with origin
-
-Your clone has a tag pointing at a different commit than `origin`'s tag of the same name. Almost always caused by having run a mirror push under an older implementation, which force-moved tags locally.
-
-A disagreeing tag is not cosmetic: `git describe` returns the wrong base, so the version derives from the wrong place and nothing complains. That is why this is asserted rather than trusted.
-
-**Fix:** the error prints it — `git fetch origin --tags --force`.
-
-### The release fails: tag exists locally but NOT on origin
-
-A previous run created the tag and failed before pushing it, so that release was never published. The error offers both recoveries: push the existing tag and create its Release object, or delete the local tag and start over.
-
-### A release is refused because it changes the MAJOR version
-
-Expected — that is the gate. Read the `hint:` line, which names the exact value:
-
-```
-error: this release changes the MAJOR version (v0.9.3 -> v1.0.0)
-hint: re-run with CONFIRM_VERSION=v1.0.0
-```
-
-Re-run with that value supplied as a CI variable. It fails before creating anything, and the value must match the derived tag exactly — so if history moves in between, the stale value stops working rather than releasing the wrong version.
-
-### The GitLab Release object is missing but the tag exists
-
-The tag push succeeded and the Release API call failed. The tag is fine.
-
-**Fix:** re-run the job, or call `infra/scripts/gitlab-release.sh ` directly. It is idempotent — an existing Release is reported as success rather than an error.
-
-### git-cliff not installed locally
-
-Only the local `make` targets need it; CI installs its own pinned copy.
-
-```bash
-brew install git-cliff
-```
-
-### Pipeline missing the manual buttons
-
-The release jobs only appear on pipelines running on the default branch (`main`). Pipelines on feature branches do not show them.
-
-## Background: Trunk-Based Workflow
-
-The project uses **trunk-based development** on `main`. Daily work lands directly on `main` or via short-lived feature branches merged back to `main`. **There is no `develop` branch**, and the release path creates no branch of its own.
-
-v0.1.6 was the last release cut under the earlier Gitflow-lite model, where `develop` was the default branch and every release meant merging `develop` into `main` and reconciling SHAs back.
-
-## Background: Version Derivation
-
-There is **no `VERSION` file**. It was retired because it conflated two different questions — "what is the next version?" (a release-time derivation) and "what is the current version?" (a build-time display) — and because being hand-maintained, it was routinely wrong: it read `0.1.8` while the newest tag was `v0.1.7` and no `v0.1.8` ever existed.
-
-Tags are the single source of truth. `git-cliff --bumped-version` answers the first question; `git describe` answers the second.
-
-Three configured behaviors matter when reading a derivation:
-
-- **`Update:`-prefixed commits are skipped entirely.** They affect neither the version nor any notes section. A batch of these predates the convention.
-- **Only strict `vX.Y.Z` tags are eligible as a derivation base.** `cliff.toml`'s `tag_pattern` enforces it, so a stray prerelease-shaped tag like `v0.9.9-rc1` cannot become the base.
-- **A breaking change stays within `0.x` while pre-1.0.** `cliff.toml` sets `breaking_always_bump_major = false`, so a `feat!:` commit derives `0.x+1` rather than `1.0.0`. This setting is **inert** once the major reaches 1 — at `v1.0.0` a `feat!:` derives `v2.0.0` normally. The guard that survives the 1.0 transition is the version-shape check in `release.sh`, which refuses any release raising the major unless `CONFIRM_VERSION` matches.
-
-### git-cliff
-
-[git-cliff](https://git-cliff.org/) generates the notes from git history by parsing Conventional Commit messages, and derives the next version from the same history. Configuration is `cliff.toml` at the repo root.
-
-CI installs a pinned `git-cliff==2.10.1` — the version every mechanism in the release path was verified against.
-
-### Conventional Commits
-
-See the public [Commit Messages](../../../../developer-docs/contributing/commit-messages.md) reference for the full type/scope table. Quick reference:
-
-| Type | Release notes section |
-|------|-----------------------|
-| `feat` | Added |
-| `fix` | Fixed |
-| `docs` | Documentation |
-| `perf` | Performance |
-| `refactor` | Changed |
-| `ci`, `build` | CI/Build |
-| `revert` | Reverted |
-| `style`, `test`, `chore`, `Update:` | (skipped) |
-
-## Reference: Scripts
-
-### infra/scripts/release.sh
-
-The whole release act, in one script, called by both `gitlab:*` jobs and runnable from a workstation. It takes **no arguments** — the version is derived, not requested.
-
-**The ordering is the design.** Everything that can refuse runs before the tag exists, because a gate that fires after tagging is not a gate:
-
-1. **Derive** with `git-cliff --bumped-version`.
-2. **Preview** (`PREVIEW=1`) — returns first, so it needs no network and cannot report notes a release would not publish.
-3. **No-op** — exits 0 when the derived version equals the newest tag, but only once `origin` confirms that tag was actually pushed.
-4. **Major guard** — refuses unless `CONFIRM_VERSION` equals the derived tag.
-5. **Tag hygiene** — every local release tag must match `origin`'s tag of the same name.
-6. **Tag absence** — refuses if the tag exists locally or on `origin`. This is the double-tag guard.
-7. **Notes**, asserting the output contains a `^## \[` heading.
-8. **Tag, push, Release object.**
-
-`--cleanup=verbatim -F` on the tag is **load-bearing rather than stylistic**. Git's default cleanup mode strips `#`-leading lines as comments and reflows content — and exits 0 while doing it, so the loss is silent. The public side (`.github/workflows/release.yml`) reads this annotation and requires `## [` headings. `verbatim` also preserves the two-space nested indentation `cliff.toml` produces, which is why its `trim = false` is mandatory for the same reason.
-
-### infra/scripts/gitlab-release.sh
-
-Creates the GitLab Release object for an existing tag, via the REST API with `CI_JOB_TOKEN`. Kept as a separate script because it is the recovery path when the tag pushed but the Release object failed — it is idempotent, reporting an existing Release as success.
-
-The REST API rather than the `release:` CI keyword: that keyword needs `release-cli`, a Go binary absent from the `python:3.12-bookworm` image the release jobs use for `git-cliff`.
-
-### infra/scripts/release.mk
-
-| Target | What it does |
-|--------|--------------|
-| `release-preview` | Delegates to `PREVIEW=1 release.sh`. Creates nothing. The local counterpart of `gitlab:release:preview`. |
-| `release` | Delegates to `release.sh`. **Tags and pushes for real** — the recovery path when CI is unavailable. |
-
-Both delegate rather than reimplement, so a local run and a CI run cannot disagree.
-
-:::note Why manual jobs on a branch pipeline rather than tag-triggered
-A tag pipeline cannot work here. `pages` requires `$CI_COMMIT_BRANCH`, which is unset on tag pipelines, and the release jobs require `$CI_COMMIT_TAG == null`. A manual job on a branch pipeline sidesteps both.
-:::
-
-## Reference: CI Variables
-
-### CONFIRM_VERSION
-
-Supplied at job-run time to `gitlab:release`, and only needed when the release raises the **major** version. Not a stored variable — run the job once, read the value from the failure message, re-run with it. It must equal the derived tag exactly.
-
-### CONFIRM_TAG
-
-Supplied at job-run time to `github:release`. Its **absence is the dry run**, so run the job once and read the value out of its output.
-
-### GITHUB_DEPLOY_KEY
-
-An SSH private key with push access to `github.com:aws-samples/sample-innovation-patterns`. Used only by `github:release`, not by any internal release job.
-
-**Setup steps:**
-
-1. Generate an SSH keypair:
- ```bash
- ssh-keygen -t ed25519 -C "gitlab-ci-deploy" -f deploy_key -N ""
- ```
-
-2. Add the **public key** to GitHub:
- - Go to `github.com/aws-samples/sample-innovation-patterns` → Settings → Deploy Keys
- - Add `deploy_key.pub` with **write access** enabled
-
-3. Base64-encode the private key (GitLab rejects masking for values containing whitespace/newlines):
- ```bash
- base64 -i deploy_key | tr -d '\n'
- ```
-
-4. Add it to GitLab under Settings → CI/CD → Variables:
- - Key: `GITHUB_DEPLOY_KEY`
- - Value: the base64 string from step 3
- - Flags: **Masked**, **Protected**
-
-5. Delete the local keypair:
- ```bash
- rm deploy_key deploy_key.pub
- ```
-
-The CI job decodes it at runtime: `printf '%s' "$GITHUB_DEPLOY_KEY" | base64 -di | ssh-add -`. A 350-byte floor on the decoded length catches a truncated or mis-pasted value before `ssh-add` fails with something less obvious.
-
-## Reference: Release Flow Diagram
-
-```mermaid
-sequenceDiagram
- participant H as Builder (human)
- participant CI as GitLab CI
- participant O as origin (GitLab)
- participant GH as GitHub
-
- H->>O: push conventional commits to main
-
- H->>CI: click gitlab:release:preview (manual)
- CI->>CI: derive, print notes — creates NOTHING
-
- H->>CI: click gitlab:release (manual)
- CI->>CI: derive, no-op check, major guard, tag hygiene, tag absence
- CI->>CI: generate notes, annotate tag --cleanup=verbatim
- CI->>O: push tag vX.Y.Z
- CI->>O: POST /releases
- Note over CI,O: internal release complete. NOTHING published to GitHub.
-
- H->>CI: click github:release, no CONFIRM_TAG (manual)
- CI->>CI: print TAG, FLOOR, notes, filtered paths — publishes NOTHING
-
- H->>CI: re-run github:release with CONFIRM_TAG
- CI->>GH: filtered force-push + tag object
- GH->>GH: release.yml reads the annotation, creates a Release
-```
-
-## Migration History
-
-**Current era — tag-only releases.** The committed `CHANGELOG.md` was retired, and with it the ephemeral release branch, the release merge request, the marker commit subject the automatic tagger keyed on, the tag-on-merge job itself, and the separate major-release jobs. Notes live on the annotated tag and the Release object. Six internal jobs and two publish jobs became three manual buttons. The v0.2.0 failure mode is structurally impossible now: no committed file exists to go stale.
-
-**Before that**, a release was prepared as a merge request carrying a regenerated changelog, reviewed as a diff, and tagged automatically when merged — the merge was the approval. All of that machinery existed to police the committed changelog, which is what made retiring the file able to retire the flow.
-
-**Before that**, cutting a release was a single button press that tagged `origin` immediately, with no review of the notes and no enforcement that the changelog had been regenerated. That is what produced v0.2.0, whose committed changelog read `## [Unreleased]` while the tag and the published Release both said 0.2.0.
-
-**Before that**, one manual job read the `VERSION` file, tagged, and mirrored to GitHub in consecutive lines of a single script — so an internal release could not be cut without also publishing publicly, and an older tag could not be published at all.
-
-### Reading the retired changelog
-
-The deleted `CHANGELOG.md` is recoverable from git history. Read it from the commit **before** the deletion:
-
-```bash
-git show "$(git log --diff-filter=D --format=%H -1 -- CHANGELOG.md)^:CHANGELOG.md"
-```
-
-:::warning Not from the v0.2.0 tag
-`git show v0.2.0:CHANGELOG.md` looks like the obvious command and returns the **wrong** file — 175 lines still headed `## [Unreleased]`, because that tag *is* the v0.2.0 staleness incident described above. The complete 647-line file exists only at the tip before deletion.
-:::
-
-v0.1.6 was the last release under the develop → main merge flow. Starting with v0.1.7, the project uses trunk-based development on `main`.
diff --git a/internal/README.md b/internal/README.md
deleted file mode 100644
index 4d6fbbd..0000000
--- a/internal/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# internal/
-
-Maintainer-only tooling for this repository — the release machinery under `release/`,
-the integration-test harness under `integration-tests/`, and the hand-installed AWS
-infrastructure that runs that harness off a laptop under `infra/`. Nothing here is part
-of what IPA delivers to a customer, and none of it is needed to use the generated
-`scripts/*.mk`.
-
-`infra/` is deliberately separate from the customer-facing `infra/cfn/` at the repository
-root: those templates are shipped and composed by `/ipa-compose`, so pointing maintainer
-CI at them would make a change made for the harness a breaking change for customers. See
-`infra/README.md`.
-
-**This directory is excluded from the public GitHub mirror.** `internal` is one of the
-seven paths filtered by `internal/release/publish-github.sh`, so these files exist on
-`code.aws.dev` only.
-
-One exception, and it matters: **`internal/release/templates/` is public interface
-despite living here.** It is the portable release standard, copied into customer
-projects, so `CLAUDE.md`'s public/internal split treats a breaking change to it as
-breaking. Everything else under `internal/` is internal interface, where no `!` applies
-however sweeping the change.
diff --git a/internal/infra/README.md b/internal/infra/README.md
deleted file mode 100644
index e761dd1..0000000
--- a/internal/infra/README.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# internal/infra/
-
-Two CloudFormation stacks that let the integration-test harness run somewhere other
-than a maintainer's laptop. **Both are installed by hand, per account.** No CI job
-deploys either, and nothing in this repository deploys them automatically.
-
-| Directory | What it creates | Needs |
-|---|---|---|
-| `codepipeline/` | A versioned S3 bucket, a narrow pipeline role, a build role with `AdministratorAccess`, one CodeBuild project, one pipeline | `CAPABILITY_IAM` |
-| `credential-vendor/` | One IAM role, assumable only by the code.aws.dev credential vendor, that can start a run of the above and read its result | `CAPABILITY_NAMED_IAM` |
-
-Neither is shipped. `internal/` is excluded from the public GitHub mirror, and unlike
-`internal/release/templates/` these are **not** public interface — see
-`internal/README.md` for that distinction.
-
-## Why these are here and not under `infra/cfn/`
-
-`infra/cfn/**` is customer-facing: those templates are composed by `/ipa-compose` and
-delivered to users, so their parameter names, outputs and logical IDs are breaking-change
-surface. `infra/cfn/codepipeline/codepipeline.yml` in particular is a **customer
-application** pipeline — it runs the generated `scripts/*.mk` targets for a deployed app.
-It is not this, and pointing the harness's own CI at it would couple maintainer tooling
-to a shipped deliverable: a change made for the harness would become a breaking change
-for every customer.
-
-That template is untouched by this directory and remains in use.
-
-## Install order
-
-`credential-vendor` references the pipeline's bucket and pipeline name, and the bucket
-name is CloudFormation-generated, so it cannot be known before the pipeline exists.
-
-```bash
-# 1. The pipeline. Dry run first — without CONFIRM=1 this prints the resolved
-# account and stops.
-make -C internal/infra/codepipeline deploy
-make -C internal/infra/codepipeline deploy CONFIRM=1
-
-# 2. Run the test once by hand, before wiring anything to it.
-make -C internal/infra/codepipeline trigger CONFIRM=1
-
-# 3. Only if GitLab CI should be able to start runs. Reads the two values it
-# needs from the pipeline stack's outputs automatically.
-make -C internal/infra/credential-vendor deploy
-make -C internal/infra/credential-vendor deploy CONFIRM=1
-```
-
-Step 3 is genuinely optional, and worth skipping until step 2 has passed at least once.
-The pipeline is fully usable by hand; the role only exists to let a CI job do what
-`make trigger` does.
-
-## Conventions both directories share
-
-- **A bare `make` prints help and creates nothing.**
-- **Every target that changes an account or spends money needs `CONFIRM=1`.** Without
- it, the target prints what it would do — including the account resolved from
- `sts:get-caller-identity`, not from a variable — and stops. `make deploy` with no
- `CONFIRM` is the dry run.
-- **`AWS_REGION` is pinned to `us-east-1` with `:=`, not `?=`.** `?=` yields to an
- exported variable, and a shell exporting `AWS_REGION=us-west-2` silently captured it —
- `make help` printed one region while the Makefile said another. A command-line
- `make AWS_REGION= ` still wins.
-- **`AWS_PROFILE` defaults only if that profile actually exists** on the machine, because
- `--profile ` is a hard failure on a host using an instance role, not a
- fall-through to ambient credentials.
-- **Destructive and dry-run logic lives in ONE shell block per target.** `exit 0` leaves
- only its own recipe line's subshell, so a guard on its own line does not stop the
- target. That bug was written and caught here: a `trigger` dry run printed "nothing was
- done" and then started a billable run.
-
-## Which account?
-
-This is an open decision, not a detail. Three values currently disagree:
-
-| Source | Account / profile |
-|---|---|
-| Both Makefiles here | `vincilb+ip-dev-Admin` (369530416671) |
-| `internal/integration-tests/preflight.py:56` | `peterpyu+code-evaluation-Admin` — where every manual run was measured |
-| This repository's `.env` | `vincilb+ip-dev-Admin` |
-
-Inside the build there is no profile at all: the buildspec creates a credential-less
-`ipa-it` profile that resolves the container's own credentials, so the harness's
-requirement for a *named* profile is satisfied without one being configured. The
-disagreement only matters for the hand-run targets, and for deciding which account owns
-the stack. Settle it before wiring CI to it.
-
-## Security posture, stated once
-
-`codepipeline/`'s `CodeBuildRole` holds `AdministratorAccess`, because the run it
-executes deploys and then deletes arbitrary infrastructure. Therefore:
-
-> `s3:PutObject` on the source key, plus `codepipeline:StartPipelineExecution`, are the
-> **entire** control on executing arbitrary code as an administrator in the target
-> account.
-
-`credential-vendor/` grants exactly those two things and nothing else — deliberately not
-`AdministratorAccess`, unlike the reference implementation it is modelled on. That
-removes one path to administrator, not the path: whatever a CI job uploads still runs
-under the build role. Read `credential-vendor/README.md` before treating the narrow role
-as a containment boundary.
-
-Unlike the reference's repository, `internal/**` here is **not** exempt from ASH. Both
-templates are scanned, and `.ash/ash.yaml` suppresses ten findings against
-`internal/infra/**` by path, each with a reason. The suppressed set was measured
-(checkov, cfn_nag, cfn-lint) rather than anticipated; KICS was not run locally and may
-add names of its own.
diff --git a/internal/infra/codepipeline/Makefile b/internal/infra/codepipeline/Makefile
deleted file mode 100644
index 60d72a0..0000000
--- a/internal/infra/codepipeline/Makefile
+++ /dev/null
@@ -1,325 +0,0 @@
-# Integration-test pipeline — the hand-run lifecycle of the stack that runs
-# internal/integration-tests on CodeBuild, and the hand-run lifecycle of a RUN.
-# Internal maintainer tooling; not shipped.
-#
-# A bare `make` prints help and creates nothing. Every target that changes an AWS
-# account or spends money requires CONFIRM=1, and WITHOUT it prints exactly what
-# it would do — against which account, read from sts:get-caller-identity rather
-# than from a variable — and stops. That is the guard that matters: the risk is
-# not a malformed template, it is landing an AdministratorAccess role in the wrong
-# account, deleting shared infrastructure other maintainers are using, or starting
-# a billable run nobody was expecting.
-#
-# This mirrors ../credential-vendor/Makefile deliberately, including the target
-# names. Two sibling directories under internal/infra/ disagreeing about which
-# operations need confirmation, or about whether the destructive one is called
-# `delete` or `destroy`, is exactly the drift that gets a guard skipped.
-#
-# THE STACK's lifecycle: validate deploy outputs status destroy
-# A RUN's lifecycle: archive upload start watch trigger logs
-#
-# The reference implementation splits those two groups into separate directories
-# so that `destroy` is not in the file invoked on every run. They are together
-# here because this is installed by hand as one unit; the compensating control is
-# that `destroy` needs CONFIRM=1 and prints what it will delete first.
-#
-# This wraps commands README.md also documents directly. Windows has no `make` —
-# use those.
-
-.DEFAULT_GOAL := help
-.PHONY: help validate deploy outputs status destroy archive upload start watch \
- trigger logs
-
-HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
-REPO_ROOT := $(abspath $(HERE)../../..)
-TEMPLATE := $(HERE)integration-test-pipeline.yml
-
-STACK_NAME ?= ipa-integration-test-pipeline
-SOURCE_OBJECT_KEY ?= source/source.zip
-
-# Written outside the repository, so an archive of the tree never contains a
-# previous archive of the tree.
-ARCHIVE ?= /tmp/ipa-integration-test-source.zip
-
-# `aws configure list-profiles` is the check rather than grepping ~/.aws/config,
-# because it reads both config and credentials files and knows their formats.
-# grep -Fx so the profile's '+' is matched literally, and so a longer profile that
-# merely CONTAINS this name does not count as a match.
-#
-# The existence check is the part that matters: `--profile ` is a HARD
-# failure on a host using an instance role, not a fall-through to ambient
-# credentials, so the default applies only where it is real.
-#
-# NOTE the account this names, because three contexts can disagree and this line
-# is where that decision is recorded.
-#
-# This profile is only for DEPLOYING this stack from a workstation. The harness
-# itself no longer has a profile default of any kind: it authenticates from the
-# default credential chain (harness/aws.py), and the constant that used to hold a
-# maintainer's profile is deleted. Inside the build there is no profile with
-# credentials either — the buildspec creates a credential-less `ipa-it` profile
-# that falls through to the container's own role.
-#
-# So the only remaining question is which account owns this stack. This
-# repository's .env names vincilb+ip-dev-Admin; the manual harness runs recorded
-# in DECISIONS.md were measured against a different, evaluation-sandbox account.
-# Decide before deploying.
-AWS_DEFAULT_PROFILE_NAME := vincilb+ip-dev-Admin
-AWS_PROFILE ?= $(shell aws configure list-profiles 2>/dev/null \
- | grep -Fxq "$(AWS_DEFAULT_PROFILE_NAME)" \
- && echo "$(AWS_DEFAULT_PROFILE_NAME)")
-
-# PINNED, and `:=` rather than `?=` — measured, not stylistic. Everything in this
-# stack is REGIONAL — a bucket, a pipeline, a build project, a log group — so a
-# stack landing in whatever region something else chose would be discovered only
-# by failing to find it.
-#
-# `?=` does not defend against that, because it yields to an EXPORTED variable.
-# The shell this was written in exports AWS_REGION=us-west-2 (the same ambient
-# value preflight.py's docstring calls out), and `?=` silently adopted it: `make
-# help` printed AWS_REGION=us-west-2 while this line said us-east-1. A simple `:=`
-# is what pins it, and a command-line `make AWS_REGION= ` still wins,
-# because a command-line variable overrides a makefile assignment.
-AWS_REGION := us-east-1
-
-# Passed as a flag, never exported. A maintainer runs this by hand under a named
-# profile, but exporting AWS_PROFILE leaks into everything else the shell later
-# runs, and an empty default keeps these same targets usable under vended
-# credentials.
-PROFILE_FLAG := $(if $(AWS_PROFILE),--profile "$(AWS_PROFILE)",)
-AWS := aws $(PROFILE_FLAG) --region "$(AWS_REGION)"
-
-# A bare python3. archive.py and empty_bucket.py are stdlib-only precisely so this
-# needs no virtualenv: teardown must not fail with an ImportError at the moment an
-# operator is trying to delete infrastructure.
-PYTHON ?= python3
-
-help: ## Show this help
- @echo "Integration-test pipeline — hand-run stack and run lifecycle"
- @echo ""
- @echo "THE STACK (deploy/destroy need CONFIRM=1):"
- @echo " deploy Create or update the stack: a versioned S3 bucket, a narrow"
- @echo " pipeline role, a build role holding AdministratorAccess, one"
- @echo " CodeBuild project and one pipeline. The pipeline is INERT —"
- @echo " uploading an archive starts nothing. Without CONFIRM=1 this"
- @echo " is a DRY RUN: it prints the target account, then stops."
- @echo " destroy Delete the stack. Empties every object VERSION and delete"
- @echo " marker first, because the bucket is versioned and"
- @echo " delete-stack fails otherwise. DESTROYS SHARED"
- @echo " INFRASTRUCTURE — any maintainer or CI job using this"
- @echo " pipeline stops being able to trigger a run."
- @echo " validate Check the template is structurally valid (creates nothing)"
- @echo " outputs Print the stack's outputs — including the two values"
- @echo " ../credential-vendor needs"
- @echo " status Print the stack's current status"
- @echo ""
- @echo "A RUN (start/trigger need CONFIRM=1 — they SPEND MONEY):"
- @echo " archive Build the source zip from the WORKING TREE, including .git."
- @echo " Free. Refuses to run from a linked git worktree."
- @echo " upload Upload the archive to the source key. Starts nothing."
- @echo " start Start one pipeline execution. BILLABLE."
- @echo " watch Poll the latest execution until it finishes."
- @echo " trigger archive + upload + start + watch. BILLABLE."
- @echo " logs Tail the most recent build's CloudWatch log stream."
- @echo ""
- @echo "COST. The stack itself is close to free at rest: an empty bucket, two IAM"
- @echo " roles, a pipeline definition and a log group. Every EXECUTION costs:"
- @echo " - BUILD_GENERAL1_LARGE build minutes, up to the template's"
- @echo " $(shell grep -A3 'BuildTimeoutMinutes:' "$(TEMPLATE)" | grep Default | tr -dc '0-9')-minute ceiling"
- @echo " - model spend, up to \$$15 on the full-lifecycle path (driver.py:371)"
- @echo " - everything the integration test itself deploys, until it is torn"
- @echo " down. post_build tears down unconditionally, but a build killed"
- @echo " outside its own lifecycle can still leave seven stacks standing."
- @echo ""
- @echo "Variables:"
- @echo " STACK_NAME=$(STACK_NAME) AWS_REGION=$(AWS_REGION)"
- @echo " SOURCE_OBJECT_KEY=$(SOURCE_OBJECT_KEY)"
- @echo " ARCHIVE=$(ARCHIVE)"
- @echo " AWS profile: $(if $(AWS_PROFILE),$(AWS_PROFILE),)"
- @echo ""
- @echo "SECURITY POSTURE, stated plainly: the build role holds"
- @echo " AdministratorAccess. s3:PutObject on the source key plus"
- @echo " codepipeline:StartPipelineExecution are the ENTIRE control on running"
- @echo " arbitrary code as an administrator in the target account. See README.md."
-
-validate: ## Check the template is structurally valid (creates nothing)
- @$(AWS) cloudformation validate-template \
- --template-body "file://$(TEMPLATE)" --output table
-
-# ONE shell block, continuations included, so the dry-run `exit 0` ends the whole
-# target. Do NOT split the post-deploy reporting into separate recipe lines:
-# `exit 0` only leaves its own line's subshell, so make would carry on and run
-# `outputs` against a stack the dry run never created — which fails with a bare
-# DescribeStacks ValidationError and makes a successful dry run look broken.
-deploy: ## Create/update the stack. Needs CONFIRM=1. CREATES AWS RESOURCES.
- @set -e; \
- echo "Deploying INTO this account — check it before confirming:"; \
- $(AWS) sts get-caller-identity \
- --query "join(' ', [Account, Arn])" --output text \
- || { echo "ERROR: no usable AWS credentials. Set AWS_PROFILE or refresh them."; exit 1; }; \
- echo ""; \
- echo "Stack: $(STACK_NAME)"; \
- echo "Region: $(AWS_REGION)"; \
- echo "Template: $(TEMPLATE)"; \
- echo ""; \
- echo "Creates a versioned S3 bucket, a narrow pipeline role, a build role"; \
- echo "holding AdministratorAccess, one CodeBuild project and one pipeline. The"; \
- echo "pipeline is INERT: PollForSourceChanges is false, so uploading an archive"; \
- echo "starts nothing."; \
- echo ""; \
- echo "The build role has NO permissions boundary and NO session cap. Write"; \
- echo "access to the source key is write access to code that runs as an"; \
- echo "administrator here."; \
- if [ "$(CONFIRM)" != "1" ]; then \
- echo ""; \
- echo "DRY RUN — nothing was deployed. To create it for real:"; \
- echo " make deploy CONFIRM=1"; \
- exit 0; \
- fi; \
- echo ""; \
- $(AWS) cloudformation deploy \
- --template-file "$(TEMPLATE)" \
- --stack-name "$(STACK_NAME)" \
- --capabilities CAPABILITY_IAM; \
- echo ""; \
- $(MAKE) --no-print-directory outputs; \
- echo ""; \
- echo "Next: 'make trigger CONFIRM=1' to run the test, or deploy the CI role"; \
- echo "with 'make -C ../credential-vendor deploy CONFIRM=1'."
-
-outputs: ## Print the stack's outputs — what the trigger and CI role read
- @$(AWS) cloudformation describe-stacks --stack-name "$(STACK_NAME)" \
- --query "Stacks[0].Outputs" --output table \
- || { echo "No stack named '$(STACK_NAME)' in $(AWS_REGION)."; \
- echo "Deploy it with: make deploy CONFIRM=1"; exit 1; }
-
-status: ## Print the stack's current status
- @$(AWS) cloudformation describe-stacks --stack-name "$(STACK_NAME)" \
- --query "Stacks[0].StackStatus" --output text \
- || { echo "No stack named '$(STACK_NAME)' in $(AWS_REGION)."; exit 1; }
-
-destroy: ## DELETE the stack. Needs CONFIRM=1. DESTROYS SHARED INFRASTRUCTURE.
- @set -e; \
- echo "Deleting stack '$(STACK_NAME)' FROM this account:"; \
- $(AWS) sts get-caller-identity \
- --query "join(' ', [Account, Arn])" --output text \
- || { echo "ERROR: no usable AWS credentials. Set AWS_PROFILE or refresh them."; exit 1; }; \
- echo ""; \
- echo "This DELETES the source bucket and everything in it, both IAM roles, the"; \
- echo "CodeBuild project and the pipeline. Any maintainer or CI job configured"; \
- echo "to trigger runs against this stack stops working, and re-creating it does"; \
- echo "not restore the archives or the run artifacts."; \
- echo ""; \
- echo "It does NOT touch infrastructure the integration test itself deployed."; \
- echo "Check 'make -C $(REPO_ROOT)/internal/integration-tests list-stacks' for that."; \
- if [ "$(CONFIRM)" != "1" ]; then \
- echo ""; \
- echo "DRY RUN — nothing was deleted. To delete it for real:"; \
- echo " make destroy CONFIRM=1"; \
- exit 0; \
- fi; \
- echo ""; \
- echo "Emptying the bucket first — it is versioned, so delete-stack fails on it"; \
- echo "until every object VERSION and delete marker is gone."; \
- "$(PYTHON)" "$(HERE)empty_bucket.py" \
- --stack "$(STACK_NAME)" --region "$(AWS_REGION)" \
- $(if $(AWS_PROFILE),--profile "$(AWS_PROFILE)",); \
- echo ""; \
- $(AWS) cloudformation delete-stack --stack-name "$(STACK_NAME)"; \
- echo "Delete requested. Waiting for it to finish..."; \
- $(AWS) cloudformation wait stack-delete-complete --stack-name "$(STACK_NAME)"; \
- echo "Deleted."
-
-# ─────────────────────────────────────────────────────────────────────────────
-# A RUN's lifecycle
-# ─────────────────────────────────────────────────────────────────────────────
-
-archive: ## Build the source zip from the working tree, including .git. Free.
- @"$(PYTHON)" "$(HERE)archive.py" --repo "$(REPO_ROOT)" --out "$(ARCHIVE)"
-
-upload: ## Upload the archive to the source key. Starts nothing.
- @set -e; \
- if [ ! -f "$(ARCHIVE)" ]; then \
- echo "No archive at $(ARCHIVE). Build it first: make archive"; exit 1; \
- fi; \
- bucket="$$($(AWS) cloudformation describe-stacks --stack-name "$(STACK_NAME)" \
- --query "Stacks[0].Outputs[?OutputKey=='SourceBucketName'].OutputValue | [0]" \
- --output text)"; \
- if [ -z "$$bucket" ] || [ "$$bucket" = "None" ]; then \
- echo "Could not read SourceBucketName from '$(STACK_NAME)'."; exit 1; \
- fi; \
- echo "Uploading $(ARCHIVE) to s3://$$bucket/$(SOURCE_OBJECT_KEY)"; \
- $(AWS) s3 cp "$(ARCHIVE)" "s3://$$bucket/$(SOURCE_OBJECT_KEY)"; \
- echo "Uploaded. Nothing started — PollForSourceChanges is false."
-
-start: ## Start ONE pipeline execution. Needs CONFIRM=1. BILLABLE.
- @set -e; \
- echo "Starting a run in this account:"; \
- $(AWS) sts get-caller-identity \
- --query "join(' ', [Account, Arn])" --output text; \
- echo ""; \
- echo "This BILLS: LARGE build minutes, up to \$$15 of model spend, and every"; \
- echo "stack the test deploys until it is torn down."; \
- if [ "$(CONFIRM)" != "1" ]; then \
- echo ""; \
- echo "DRY RUN — nothing was started. To run it for real:"; \
- echo " make start CONFIRM=1"; \
- exit 0; \
- fi; \
- echo ""; \
- $(AWS) codepipeline start-pipeline-execution --name "$(STACK_NAME)" \
- --query "pipelineExecutionId" --output text
-
-# Polls the LATEST execution rather than an id threaded out of `start`, so this is
-# usable on a run someone else started, and after a lost terminal.
-watch: ## Poll the latest execution until it finishes
- @set -e; \
- while :; do \
- summary="$$($(AWS) codepipeline list-pipeline-executions \
- --pipeline-name "$(STACK_NAME)" --max-items 1 \
- --query "pipelineExecutionSummaries[0].[pipelineExecutionId,status]" \
- --output text)"; \
- id="$$(echo "$$summary" | awk '{print $$1}')"; \
- state="$$(echo "$$summary" | awk '{print $$2}')"; \
- echo "$$(date -u +%H:%M:%S) $$id $$state"; \
- case "$$state" in \
- Succeeded) exit 0 ;; \
- Failed|Cancelled|Superseded|Stopped) exit 1 ;; \
- esac; \
- sleep 30; \
- done
-
-# ONE shell block, the four sub-makes included. This is the same trap the `deploy`
-# comment above describes, and it was WRITTEN here first: with the guard on its own
-# recipe line, `exit 0` left only that line's subshell and make carried on to
-# `archive`, `upload`, `start CONFIRM=1` and `watch`. A dry run printed "nothing was
-# done" and then started a billable run. Measured, not hypothetical — it was caught
-# only because a linked worktree made `archive` fail first.
-#
-# So: never put a CONFIRM guard on its own recipe line in this file. The cost of
-# getting it wrong here is money, not a confusing error.
-trigger: ## archive + upload + start + watch. Needs CONFIRM=1. BILLABLE.
- @set -e; \
- if [ "$(CONFIRM)" != "1" ]; then \
- echo "This builds an archive, uploads it, starts a BILLABLE run and waits."; \
- echo ""; \
- echo "DRY RUN — nothing was done. To run it for real:"; \
- echo " make trigger CONFIRM=1"; \
- exit 0; \
- fi; \
- $(MAKE) --no-print-directory archive; \
- $(MAKE) --no-print-directory upload; \
- $(MAKE) --no-print-directory start CONFIRM=1; \
- $(MAKE) --no-print-directory watch
-
-logs: ## Tail the most recent build's CloudWatch log stream
- @set -e; \
- group="/aws/codebuild/$(STACK_NAME)-lifecycle"; \
- stream="$$($(AWS) logs describe-log-streams --log-group-name "$$group" \
- --order-by LastEventTime --descending --max-items 1 \
- --query "logStreams[0].logStreamName" --output text)"; \
- if [ -z "$$stream" ] || [ "$$stream" = "None" ]; then \
- echo "No log streams in $$group yet."; exit 1; \
- fi; \
- echo "Tailing $$group / $$stream"; \
- $(AWS) logs tail "$$group" --log-stream-names "$$stream" --follow
diff --git a/internal/infra/codepipeline/README.md b/internal/infra/codepipeline/README.md
deleted file mode 100644
index 799920a..0000000
--- a/internal/infra/codepipeline/README.md
+++ /dev/null
@@ -1,227 +0,0 @@
-# Integration-test pipeline
-
-Runs `internal/integration-tests` on CodeBuild instead of a maintainer's laptop, started
-on demand. Deployed by hand, per account. Internal maintainer tooling — not shipped.
-
-The pipeline is **inert**: `PollForSourceChanges: false`, so uploading an archive starts
-nothing. A billable run happens only when someone asks for one.
-
-## Files
-
-| File | Purpose |
-|---|---|
-| `integration-test-pipeline.yml` | The stack: bucket, two roles, log group, CodeBuild project, pipeline |
-| `Makefile` | Both lifecycles — the stack's (`deploy`/`destroy`) and a run's (`trigger`) |
-| `archive.py` | Builds the source zip from the **working tree**, including `.git` |
-| `empty_bucket.py` | Empties every object version and delete marker before `delete-stack` |
-
-## Quick start
-
-```bash
-make -C internal/infra/codepipeline # help; creates nothing
-make -C internal/infra/codepipeline validate # template check; free
-make -C internal/infra/codepipeline deploy # DRY RUN: prints the account
-make -C internal/infra/codepipeline deploy CONFIRM=1 # create it
-make -C internal/infra/codepipeline trigger CONFIRM=1 # archive+upload+start+watch
-make -C internal/infra/codepipeline logs # tail the build
-```
-
-A run's lifecycle is also available piecewise: `archive` (free), `upload` (free, starts
-nothing), `start CONFIRM=1`, `watch`.
-
-## Cost
-
-The stack at rest is close to free: an empty bucket, two IAM roles, a pipeline definition
-and a log group. Every **execution** costs:
-
-- `BUILD_GENERAL1_LARGE` build minutes, up to the 120-minute `BuildTimeoutMinutes`
- ceiling. The agent-driven run's wall clock is not measured end to end, which is why
- that ceiling exists — a stalled run otherwise burns the whole budget on the most
- expensive compute available, waiting for nothing.
-- Model spend, **which scenario you selected decides how much**. See below.
-- Everything the test itself deploys — for the `deploy` scenario, seven stacks including
- CloudFront, ECR, two Lambdas, SQS, DynamoDB and S3 — until it is torn down. The
- `compose` scenario deploys one stack of two IAM roles: free, but real.
-
-### Which scenario runs, and what it costs
-
-The `Scenario` parameter selects it, constrained to the scenarios that exist:
-
-| `Scenario` | Drives | Model spend | Deploys |
-|---|---|---|---|
-| `compose` *(default)* | `/ipa-init`, `/ipa-compose` | **$0.94 — measured** | 1 free-but-real IAM stack |
-| `deploy` | the full lifecycle | **unmeasured**, ceiling $15 | 7 billable stacks |
-
-**The default is `compose`, and that is a spend decision rather than a convenience.**
-Deploying a pipeline and pressing Start is the first thing anyone does with it, so
-whatever the default is gets spent — about $1 here instead of about $15.
-
-`deploy`'s cost is **unmeasured, not estimated**: the run has never completed, so no figure
-is given rather than a predicted one.
-
-Each scenario has its own ceiling, and they are separate on purpose:
-
-| Parameter | Bounds | Default |
-|---|---|---|
-| `MaxBudgetUsd` → `IPA_IT_MAX_BUDGET_USD` | the `compose` drive | `5` |
-| `FullMaxBudgetUsd` → `IPA_IT_FULL_MAX_BUDGET_USD` | the `deploy` drive | `15` |
-
-One shared value would be wrong in both directions: sized for `compose`, it caps a deploy
-run at $5 and stops it partway through a deployment — indistinguishable from a wedge, and
-leaving partial infrastructure standing. `FullMaxBudgetUsd` was previously unreachable,
-because `driver.py` hardcoded the full-lifecycle default inside `full_lifecycle_options`.
-
-A mistyped scenario is rejected when this stack's **configuration is applied**
-(`AllowedValues`), not deferred to a Makefile error inside a build after LARGE compute has
-already been provisioned. The umbrella `Makefile` has its own guard for the same mistake
-made by hand; neither covers the other's path.
-
-`post_build` tears down **unconditionally**, so a failed build still cleans up. A build
-killed outside its own lifecycle (timeout, `stop-build`) can still leave stacks standing;
-`make -C internal/integration-tests list-stacks` is how you find them.
-
-Lower `BuildTimeoutMinutes` once one passing run has been measured. Right now nothing in
-this repository knows how long a green run takes.
-
-## The two things most likely to bite
-
-### The archive must contain `.git`
-
-`workspace.py` builds the temp project by copying the **working tree**, and it shells out
-to `git ls-files`, `git rev-parse HEAD` and `git status --porcelain` with `check=True`
-(`workspace.py:72-101`). An archive without `.git` therefore fails inside a pytest
-fixture with a bare `CalledProcessError`, not with a cause.
-
-`archive.py` includes `.git` for exactly this reason, and the buildspec asserts its
-presence in the install phase so a wrongly-built archive fails in seconds with an
-actionable message. `git archive HEAD` is **not** a substitute: it archives the commit,
-so uncommitted edits vanish, and it carries no git metadata at all.
-
-Consequences worth knowing:
-
-- **A dirty tree stays dirty in the archive**, and the build prints a warning naming it.
- That is deliberate — testing an uncommitted skill edit remotely is a real use, and the
- honest failure mode is a loud warning rather than a silently substituted commit.
-- **`archive.py` refuses to run from a linked git worktree**, where `.git` is a *file*
- containing a `gitdir:` pointer rather than a directory. Zipping that pointer produces
- an archive whose every git call fails on a path that does not exist in the container.
- Build from the main checkout, or pass `--repo`.
-- The archive is around **11 MiB** (694 non-ignored files plus 401 `.git` files, measured).
- Executable bits survive the zip and CodePipeline's unzip — verified against
- `infra/scripts/*.sh`.
-
-The archive excludes git-ignored files, which is what keeps `.env` and the generated
-`scripts/*.mk` out. Their absence is load-bearing: it is what keeps `/ipa-init` on its
-first-run path, whose prompts the harness can intercept. Only `scripts/test.mk` appears,
-because it is tracked — the same exception `workspace.GATING_MAKEFILES` exists to handle.
-
-### The harness names no profile; the GENERATED artifacts do
-
-**The harness authenticates from the default credential chain.** Every AWS call it makes
-resolves credentials without naming a profile — see `harness/aws.py`, the single
-definition — so inside a build they come from the container's own role. There is nothing
-to work around on that side any more.
-
-What still needs a profile *name* is the **driven project**. The agent writes
-`AWS_PROFILE` into its `.env` (four shipped skills stop when that key is absent), and the
-generated Makefiles then pass `--profile ipa-it` to the AWS CLI. So the buildspec creates
-a profile that exists and carries nothing but a region:
-
-```bash
-aws configure set region "$IPA_IT_REGION" --profile ipa-it
-```
-
-A profile section that defines **no credentials** still resolves them. botocore drops only
-`env_provider` when a profile is named explicitly and keeps `container_provider`, which
-reads CodeBuild's own credentials. The profile must *exist*, though — `--profile `
-is a hard CLI failure, not a fall-through — which is what that one line creates, and why
-creating none at all would break every generated invocation.
-
-The build environment therefore sets `AWS_PROFILE=ipa-it` (it was `IPA_IT_PROFILE`, a
-variable the harness no longer reads at all).
-
-**Both of those fall-throughs are inferred from the documented provider order, not
-measured** — no run has yet been started from inside CodeBuild. `DECISIONS.md` labels them
-as inferred. The install phase's credential check is positioned so that if the inference is
-wrong, the build fails there, for free, rather than mid-drive after spending.
-
-## What the buildspec does, and why
-
-- **`uv sync --all-extras`, and `--all-extras` on every later `uv run`.** The Claude
- Agent SDK is behind an optional extra so the offline tier collects without it. A plain
- `uv run` syncs only the default dependencies and would *remove* `claude-agent-sdk`,
- so the extra has to be named every time.
-- **`PYTEST='uv run --all-extras python -m pytest'` passed to `make test-ci`.** A
- command-line variable overrides the Makefile's own assignment, so the harness needs no
- edit to run here. It must keep `python -m pytest`: only `-m` places the working directory
- on `sys.path`, which is what makes the `harness` package importable without a build
- backend. With a bare `pytest` console script every test module fails with
- `ModuleNotFoundError: No module named 'harness'`.
-- **`SCENARIO="$IPA_IT_SCENARIO"` passed to `make test-ci`.** Selects which scenario folder
- runs. A command-line variable propagates to sub-makes through `MAKEFLAGS`, so it reaches
- the scenario's own Makefile without the umbrella forwarding it.
-- **The bundled `claude` binary is verified, and not npm-installed as a fallback.**
- `_find_cli` prefers the bundle over `PATH` (`subprocess_cli.py:249-252`), so an
- `npm install -g` would be ignored while appearing to fix the problem.
-- **`python -m harness.preflight --probes-only`** prints the nine-row prerequisite table. It
- reports rather than gates; `test-ci`'s own inline `IPA_IT_STRICT_PREFLIGHT=1` is what
- turns an unmet prerequisite into a failure instead of a silent skip — the difference
- between a build that tested nothing and a build that says so. `--probes-only` skips the
- Cognito blocking check, which deploys a user pool; the run deploys one anyway.
-- **The namespace is written to a file, not exported.** Buildspec phases do not share
- shell state, and `post_build` must know the namespace even when the build phase died
- early — which is the case that most needs tearing down. `it` + 10 hex characters is 12
- characters, exactly `answers.NS_MAX_LEN`.
-- **`IPA_IT_TEARDOWN` and `IPA_IT_STRICT_PREFLIGHT` are deliberately NOT project
- environment variables.** Both are set inline by the `test-ci` and `teardown-namespace`
- recipes. Declaring either on the project would make it ambient across every phase of
- every build — precisely the "persists invisibly" case the two-part teardown opt-in
- exists to prevent, and it would defeat that mechanism *silently*, because the
- invocation that needs it still works.
-
-## `standard:8.0`, not `7.0`
-
-This repository uses Node 22 (`.gitlab-ci.yml:51`), and Node 22 is not available in
-`aws/codebuild/standard:7.0`. The `BuildImage` parameter and the buildspec's
-`runtime-versions` block must move together — if the install phase fails with "Unknown
-runtime version", that pairing is why. uv downloads its own Python when the image's does
-not satisfy `>=3.12`, so the `python: 3.12` pin is belt and uv is braces; the `nodejs`
-pin is not recoverable that way.
-
-## Destroying it
-
-```bash
-make -C internal/infra/codepipeline destroy # DRY RUN
-make -C internal/infra/codepipeline destroy CONFIRM=1
-```
-
-The bucket is **versioned** — mandatory, because CodePipeline's S3 source action refuses
-a bucket without versioning. `aws s3 rm --recursive` leaves every noncurrent version and
-delete marker behind, and `delete-stack` then fails on a bucket that lists as empty.
-`empty_bucket.py` paginates over both `Versions` *and* `DeleteMarkers` and batches deletes
-in pages of 1000; a shell one-liner using `--query` piped into `delete-objects` silently
-truncates at the first page, producing exactly that misleading failure.
-
-It is stdlib-only and shells out to `aws s3api` rather than importing boto3, so teardown
-runs under a bare `python3`. Depending on the harness's uv project would make teardown
-fail with an `ImportError` at the moment an operator is trying to delete infrastructure.
-
-`destroy` does **not** touch infrastructure the integration test itself deployed. Check
-`make -C internal/integration-tests list-stacks` for that.
-
-## Verified, and not
-
-**Verified locally:** `aws cloudformation validate-template` passes and reports
-`CAPABILITY_IAM` (which is what `deploy` passes); cfn-lint 4.x reports nothing; checkov
-reports 26 passed / 2 failed (`CKV_AWS_18`, `CKV_AWS_158`); cfn_nag reports 0 failures
-and 3 warnings (`W32`, `W84`, `W35`); `archive.py` produces a correct archive and
-correctly refuses a worktree; every `CONFIRM`-guarded target's dry run stops without
-acting.
-
-**Not verified:** no run of this pipeline has ever executed. The stack has not been
-deployed, so the buildspec is unproven end to end — the runtime pairing, the
-credential-less profile inside CodeBuild, `dockerd` starting, and whether the harness
-completes at all on this compute are all untested. Expect to iterate on the install phase
-during the first run. Also note that `make test-compose` is currently **red** on a
-maintainer's laptop (`/ipa-compose` generates none of its claimed artifacts), so a first
-pipeline run is expected to fail for that reason and not because of anything here.
diff --git a/internal/infra/codepipeline/archive.py b/internal/infra/codepipeline/archive.py
deleted file mode 100644
index b82f850..0000000
--- a/internal/infra/codepipeline/archive.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""Build the source archive the pipeline runs, from the repository's WORKING TREE.
-
-WHAT GOES IN, AND WHY IT IS NOT ``git archive``
-----------------------------------------------
-Two sets, unioned:
-
-1. Every non-ignored file — ``git ls-files`` plus ``git ls-files --others
- --exclude-standard``. This is *exactly* the set
- ``internal/integration-tests/workspace.py`` copies into the temp project, so what
- the pipeline tests is what a local run tests. Ignored files are excluded, which is
- what keeps ``.env`` and the generated ``scripts/*.mk`` out — and their absence is
- what keeps ``/ipa-init`` on its first-run path.
-2. **The ``.git`` directory.** ``git archive HEAD`` would give neither: it archives
- the COMMIT, so uncommitted edits vanish, and it carries no git metadata at all.
- ``workspace.py`` shells out to ``git ls-files``, ``git rev-parse HEAD`` and ``git
- status --porcelain`` with ``check=True``, so an archive without ``.git`` does not
- fail here with a cause — it fails inside a pytest fixture with a
- ``CalledProcessError``.
-
-The result is that a DIRTY tree stays dirty in the archive and is reported as such by
-the build. That is deliberate: testing an uncommitted skill edit remotely is a real
-use, and the honest failure mode is a loud warning, not a silently substituted commit.
-
-WHY A WORKTREE IS REFUSED
--------------------------
-In a linked worktree ``.git`` is a FILE containing a ``gitdir:`` pointer to the parent
-repository, not a directory. Zipping that pointer produces an archive whose git
-commands all fail on a path that does not exist in the build container. Refused up
-front, by name, rather than diagnosed from CodeBuild logs.
-
-Internal maintainer tooling. Not shipped — see ``internal/README.md``.
-"""
-
-from __future__ import annotations
-
-import argparse
-import os
-import subprocess # nosec B404 - argv lists only, shell=False
-import sys
-import zipfile
-from pathlib import Path
-
-#: Resolved from this file, never from the caller's cwd:
-#: internal/infra/codepipeline/archive.py -> repository root.
-REPO_ROOT = Path(__file__).resolve().parents[3]
-
-
-def _git(repo: Path, *args: str) -> list[str]:
- """Run git with an argv list and return its non-empty stdout lines."""
- proc = subprocess.run( # nosec B603 B607 - argv list, shell=False
- ["git", *args],
- cwd=repo,
- capture_output=True,
- text=True,
- check=True,
- )
- return [line for line in proc.stdout.splitlines() if line.strip()]
-
-
-def tracked_and_untracked(repo: Path) -> list[str]:
- """Every file in the working tree that is not ignored.
-
- The same union ``workspace.tracked_and_untracked`` builds. Kept as its own
- implementation rather than importing that module: this script must run under a
- bare ``python3`` from any directory, and importing the harness would drag in its
- uv project.
- """
- tracked = _git(repo, "ls-files")
- untracked = _git(repo, "ls-files", "--others", "--exclude-standard")
- return sorted(set(tracked) | set(untracked))
-
-
-def assert_real_git_dir(repo: Path) -> None:
- """Refuse a linked worktree, which has a ``.git`` FILE rather than a directory."""
- git_path = repo / ".git"
- if git_path.is_dir():
- return
- if git_path.is_file():
- raise SystemExit(
- f"{repo} is a linked git worktree ({git_path} is a file, not a "
- "directory).\n"
- " Its .git pointer names a path that will not exist in the build "
- "container, so every git call the harness makes would fail there.\n"
- " Build the archive from the main checkout instead, or pass --repo "
- "pointing at it."
- )
- raise SystemExit(f"{repo} is not a git repository: no {git_path}")
-
-
-def _git_dir_files(repo: Path) -> list[str]:
- """Every file under ``.git``, as repo-relative POSIX paths."""
- rels: list[str] = []
- for dirpath, _dirnames, filenames in os.walk(repo / ".git"):
- for name in filenames:
- full = Path(dirpath) / name
- # Symlinks and sockets inside .git are not portable and not needed.
- if full.is_file() and not full.is_symlink():
- rels.append(full.relative_to(repo).as_posix())
- return sorted(rels)
-
-
-def _add(zf: zipfile.ZipFile, repo: Path, rel: str) -> bool:
- """Add one file, preserving its permission bits. Returns whether it was added."""
- src = repo / rel
- # ls-files can name a path that no longer exists (a staged deletion).
- if not src.is_file():
- return False
- info = zipfile.ZipInfo.from_file(src, arcname=rel)
- # from_file already carries the mode in external_attr, which is what preserves
- # the executable bit through CodePipeline's unzip. Losing it would surface as a
- # "permission denied" on a shell script during the run, not here.
- zf.writestr(info, src.read_bytes())
- return True
-
-
-def build(repo: Path, out: Path) -> tuple[int, bool, str]:
- """Write the archive. Returns ``(file_count, dirty, commit)``."""
- assert_real_git_dir(repo)
-
- commit = _git(repo, "rev-parse", "HEAD")[0]
- dirty = bool(_git(repo, "status", "--porcelain"))
-
- rels = tracked_and_untracked(repo)
- git_rels = _git_dir_files(repo)
-
- out.parent.mkdir(parents=True, exist_ok=True)
- count = 0
- with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
- for rel in rels:
- count += _add(zf, repo, rel)
- for rel in git_rels:
- _add(zf, repo, rel)
-
- return count, dirty, commit
-
-
-def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(
- description="Build the integration-test pipeline's source archive."
- )
- parser.add_argument(
- "--repo",
- default=str(REPO_ROOT),
- help="Repository to archive. Defaults to the checkout this file lives in.",
- )
- parser.add_argument("--out", required=True, help="Path of the zip to write.")
- args = parser.parse_args(argv)
-
- repo = Path(args.repo).resolve()
- out = Path(args.out).resolve()
-
- count, dirty, commit = build(repo, out)
- size_mb = out.stat().st_size / (1024 * 1024)
-
- print(f"archive: {out} ({size_mb:.1f} MiB)")
- print(f"repo: {repo}")
- print(f"commit: {commit}{' (DIRTY)' if dirty else ''}")
- print(f"files: {count} non-ignored, plus .git")
- if dirty:
- # Printed, not fatal. A dirty archive is a legitimate thing to run; a dirty
- # archive nobody noticed is not.
- print(
- "\nWARNING: the working tree has uncommitted changes. This archive "
- "contains them,\n"
- " so the commit above does not fully describe what will run.",
- file=sys.stderr,
- )
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/internal/infra/codepipeline/empty_bucket.py b/internal/infra/codepipeline/empty_bucket.py
deleted file mode 100644
index c5fd75e..0000000
--- a/internal/infra/codepipeline/empty_bucket.py
+++ /dev/null
@@ -1,206 +0,0 @@
-"""Empty the pipeline stack's VERSIONED bucket so ``delete-stack`` can succeed.
-
-WHY THIS IS PYTHON WHEN THE REST OF THE MAKEFILE IS RAW ``aws`` CALLS
---------------------------------------------------------------------
-This is the one piece of real logic in the stack's lifecycle. ``aws s3 rm
---recursive`` leaves every noncurrent version and every delete marker behind, and
-``delete-stack`` then fails on a bucket the operator believes is empty. Doing it
-properly needs pagination over TWO distinct response keys — ``Versions`` and
-``DeleteMarkers`` — and batching in pages of up to 1000. A shell one-liner using
-``--query`` piped into ``delete-objects`` silently truncates at the first page,
-which produces exactly that misleading failure.
-
-WHY IT SHELLS OUT TO ``aws s3api`` INSTEAD OF IMPORTING boto3
--------------------------------------------------------------
-``internal/integration-tests/reaper.py`` sets the precedent, and there is a
-concrete reason here: this stack's lifecycle must be runnable with a bare
-``python3``, which has no boto3. The harness's own dependencies live in a uv
-project under ``internal/integration-tests``, which has nothing to do with this
-stack — depending on it would make teardown fail with an ``ImportError`` at the
-exact moment an operator is trying to delete infrastructure.
-
-Internal maintainer tooling. Not shipped — see ``internal/README.md``.
-"""
-
-from __future__ import annotations
-
-import argparse
-import json
-import subprocess # nosec B404 - runs the AWS CLI with hardcoded argv lists and shell=False
-import sys
-
-#: ``delete-objects`` accepts at most 1000 keys per call. Exceeding it is a hard API
-#: error, not a silent truncation — but building the batches here is what makes that
-#: irrelevant.
-DELETE_BATCH_SIZE = 1000
-
-
-def _aws(args: list[str], *, profile: str | None, region: str | None) -> str:
- """Run one AWS CLI command and return stdout, raising with stderr on failure."""
- argv = ["aws"]
- if profile:
- argv += ["--profile", profile]
- if region:
- argv += ["--region", region]
- argv += args
-
- proc = subprocess.run( # nosec B603 B607 - hardcoded argv, shell=False; `aws` from PATH deliberately, since install locations differ across platforms
- argv, capture_output=True, text=True, check=False
- )
- if proc.returncode != 0:
- raise RuntimeError(
- f"`{' '.join(argv)}` failed: {(proc.stderr or proc.stdout).strip()}"
- )
- return proc.stdout
-
-
-def resolve_bucket(stack: str, *, profile: str | None, region: str | None) -> str:
- """Read the bucket name from the stack's own output.
-
- Resolved from the stack rather than taken as a literal, so this cannot empty the
- wrong bucket because someone mistyped a name next to a ``--force``-shaped
- operation.
- """
- out = _aws(
- [
- "cloudformation",
- "describe-stacks",
- "--stack-name",
- stack,
- "--query",
- "Stacks[0].Outputs[?OutputKey=='SourceBucketName'].OutputValue | [0]",
- "--output",
- "text",
- ],
- profile=profile,
- region=region,
- ).strip()
-
- if not out or out == "None":
- raise RuntimeError(
- f"stack {stack!r} has no SourceBucketName output — refusing to guess a "
- "bucket name for a delete operation"
- )
- return out
-
-
-def _iter_pages(bucket: str, *, profile: str | None, region: str | None):
- """Yield each page of ``list-object-versions`` for the whole bucket.
-
- Paginated by hand via ``--starting-token`` rather than relying on the CLI's own
- pagination, so the page size is ours and every page is observed.
- """
- token: str | None = None
- while True:
- args = [
- "s3api",
- "list-object-versions",
- "--bucket",
- bucket,
- "--max-items",
- str(DELETE_BATCH_SIZE),
- "--output",
- "json",
- ]
- if token:
- args += ["--starting-token", token]
-
- page = json.loads(_aws(args, profile=profile, region=region) or "{}")
- yield page
-
- token = page.get("NextToken")
- if not token:
- return
-
-
-def collect_targets(page: dict) -> list[dict]:
- """Every deletable identifier on one page.
-
- BOTH keys matter. ``Versions`` holds object versions; ``DeleteMarkers`` holds the
- tombstones left by earlier deletions. A bucket with only delete markers left looks
- empty to ``aws s3 ls`` and still blocks ``delete-stack``.
- """
- targets: list[dict] = []
- for key in ("Versions", "DeleteMarkers"):
- for entry in page.get(key) or []:
- targets.append({"Key": entry["Key"], "VersionId": entry["VersionId"]})
- return targets
-
-
-def batched(items: list[dict], size: int = DELETE_BATCH_SIZE):
- """Split into API-sized batches. The truncation a shell one-liner does silently."""
- for start in range(0, len(items), size):
- yield items[start : start + size]
-
-
-def empty_bucket(bucket: str, *, profile: str | None, region: str | None) -> int:
- """Remove every object version and delete marker. Returns the number removed."""
- removed = 0
- for page in _iter_pages(bucket, profile=profile, region=region):
- targets = collect_targets(page)
- if not targets:
- continue
- for batch in batched(targets):
- payload = json.dumps({"Objects": batch, "Quiet": True})
- _aws(
- [
- "s3api",
- "delete-objects",
- "--bucket",
- bucket,
- "--delete",
- payload,
- ],
- profile=profile,
- region=region,
- )
- removed += len(batch)
- print(f" removed {removed} version(s)/marker(s)...", flush=True)
- return removed
-
-
-def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(
- description="Empty the pipeline stack's versioned bucket before delete-stack."
- )
- parser.add_argument("--stack", required=True, help="Pipeline stack name.")
- parser.add_argument("--region", default=None, help="AWS region.")
- parser.add_argument("--profile", default=None, help="AWS profile, if any.")
- parser.add_argument(
- "--bucket",
- default=None,
- help="Override the bucket name instead of reading the stack output. For a "
- "stack that has already been partially deleted.",
- )
- args = parser.parse_args(argv)
-
- try:
- bucket = args.bucket or resolve_bucket(
- args.stack, profile=args.profile, region=args.region
- )
- except RuntimeError as exc:
- # A stack that is already gone is not a failure: the goal state is "the bucket
- # has nothing in it", and an absent bucket satisfies that.
- print(f"Could not resolve the bucket ({exc}).", file=sys.stderr)
- print("Nothing to empty. Continuing.", file=sys.stderr)
- return 0
-
- print(f"Emptying s3://{bucket} (every version and delete marker)...")
- try:
- removed = empty_bucket(bucket, profile=args.profile, region=args.region)
- except RuntimeError as exc:
- if "NoSuchBucket" in str(exc):
- print("Bucket is already gone — nothing to empty.")
- return 0
- print(f"ERROR: {exc}", file=sys.stderr)
- return 1
-
- if removed:
- print(f"Removed {removed} version(s)/marker(s) from s3://{bucket}.")
- else:
- print(f"s3://{bucket} was already empty.")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/internal/infra/codepipeline/integration-test-pipeline.yml b/internal/infra/codepipeline/integration-test-pipeline.yml
deleted file mode 100644
index 6e46b11..0000000
--- a/internal/infra/codepipeline/integration-test-pipeline.yml
+++ /dev/null
@@ -1,615 +0,0 @@
-AWSTemplateFormatVersion: '2010-09-09'
-Description: >-
- Runs internal/integration-tests on CodeBuild instead of a maintainer's laptop,
- started on demand. Deployed by hand, per account. Internal maintainer artifact
- — not shipped to customers. See README.md beside this file.
-
-# ─────────────────────────────────────────────────────────────────────────────
-# SECURITY POSTURE, stated here as well as in README.md because a reader of this
-# file may never open that one: CodeBuildRole carries AdministratorAccess. The
-# run it executes deploys and then deletes arbitrary infrastructure — Lambda,
-# API Gateway, CloudFront, Cognito, ECR, IAM roles with explicit names — so
-# enumerating actions chases a moving target through billable runs.
-#
-# Consequently s3:PutObject on the source key and
-# codepipeline:StartPipelineExecution are the ENTIRE control on executing
-# arbitrary code as an administrator in this account. ../credential-vendor/
-# grants exactly those two things and nothing else, which is the reason it is
-# not itself an administrator.
-#
-# Unlike the reference implementation's account, internal/** here is NOT exempt
-# from ASH — `.ash/ash.yaml` suppresses findings against `internal/infra/**`
-# path by path, each with a reason. So this template IS scanned, and a new
-# finding will fail the security job rather than pass silently.
-# ─────────────────────────────────────────────────────────────────────────────
-
-Parameters:
- SourceObjectKey:
- Type: String
- Default: 'source/source.zip'
- Description: >-
- Key the trigger uploads the archive to. The pipeline's source action
- watches exactly this key and nothing else.
-
- ArtifactRetentionDays:
- Type: Number
- Default: 30
- MinValue: 1
- MaxValue: 365
- Description: >-
- Days a run's collected output is kept. Bounded on purpose: a run's
- transcript and standing-resource report name account-specific values, so
- this output is not indefinitely-retainable material. 30 days is long enough
- to triage a failed run.
-
- SourceVersionRetentionDays:
- Type: Number
- Default: 30
- MinValue: 1
- MaxValue: 365
- Description: >-
- Days a superseded archive version is kept. Versioning is mandatory (the S3
- source action requires it), so without an expiry the bucket accumulates
- every archive ever uploaded — and each one carries the whole repository.
-
- BuildTimeoutMinutes:
- Type: Number
- Default: 120
- MinValue: 5
- MaxValue: 480
- Description: >-
- Hard ceiling on one run. The agent-driven test's wall clock is unmeasured
- end to end, and a run that stalls burns this whole budget on LARGE compute
- — the most expensive way available to wait for nothing. Lower it once one
- passing run has been measured.
-
- BuildImage:
- Type: String
- Default: 'aws/codebuild/standard:8.0'
- Description: >-
- 8.0, NOT 7.0, and this is the one parameter most likely to need changing.
- The runtime pairing below is the constraint: this repository uses Node 22
- (.gitlab-ci.yml:51 and infra/cfn/codepipeline/codepipeline.yml), and Node 22
- is not available in standard:7.0. If the install phase fails on an unknown
- runtime version, this and the buildspec runtime-versions block must move
- together.
-
- ComputeType:
- Type: String
- Default: 'BUILD_GENERAL1_LARGE'
- AllowedValues:
- - 'BUILD_GENERAL1_SMALL'
- - 'BUILD_GENERAL1_MEDIUM'
- - 'BUILD_GENERAL1_LARGE'
- Description: >-
- LARGE by default. The run builds container images and a frontend bundle;
- smaller compute makes an unmeasured wall clock longer against a fixed
- timeout, which converts a slow run into a failed one.
-
- DriverModelId:
- Type: String
- Default: 'us.anthropic.claude-sonnet-4-20250514-v1:0'
- Description: >-
- Model the driver session itself runs on, via Bedrock. Must match a model
- this account has invoke access to in this region — preflight probes it
- before the run rather than discovering it mid-drive.
-
- WorkerModelId:
- Type: String
- Default: 'anthropic.claude-3-haiku-20240307-v1:0'
- Description: 'Model the deployed queue worker invokes.'
-
- MaxBudgetUsd:
- Type: String
- Default: '5'
- Description: >-
- Model-spend ceiling for the COMPOSE drive only, surfaced as
- IPA_IT_MAX_BUDGET_USD. The full-lifecycle drive has its OWN ceiling —
- FullMaxBudgetUsd below — and raising this one does not raise that one.
- They are separate on purpose: the two differ threefold, so a single shared
- value set for compose would cap a deploy run at $5 and stop it partway
- through a deployment, which is indistinguishable from a wedge and leaves
- partial infrastructure standing.
-
- FullMaxBudgetUsd:
- Type: String
- Default: '15'
- Description: >-
- Model-spend ceiling for the FULL-LIFECYCLE drive, surfaced as
- IPA_IT_FULL_MAX_BUDGET_USD. Only reached when Scenario is 'deploy'.
- Previously unreachable: driver.py hardcoded the 15.0 default inside
- full_lifecycle_options, so the documented spend variable could not bound
- the only path able to exhaust it.
-
- Scenario:
- Type: String
- Default: 'compose'
- AllowedValues:
- - 'compose'
- - 'deploy'
- Description: >-
- Which scenario `make test-ci` runs. 'compose' drives /ipa-init and
- /ipa-compose and stops, at a MEASURED $0.94; 'deploy' drives the full
- lifecycle and deploys seven billable stacks at an UNMEASURED cost.
- Defaults to 'compose' so that deploying this pipeline and pressing Start
- costs about $1 rather than about $15 — the default is a spend decision,
- because Start is the first thing anyone does with a new pipeline.
- AllowedValues is what rejects a mistyped scenario when this stack's
- configuration is APPLIED, rather than deferring it to a Makefile error
- inside a build after LARGE compute has already been provisioned.
-
-Resources:
- # ── One bucket, two prefixes ────────────────────────────────────────────────
- # source/ holds the archive; artifacts/ holds pipeline artifacts and collected
- # run output. One bucket means one policy and one lifecycle configuration to
- # reason about, rather than two of each that can drift apart.
- SourceBucket:
- Type: AWS::S3::Bucket
- UpdateReplacePolicy: Retain
- DeletionPolicy: Delete
- Properties:
- # REQUIRED, not a preference: CodePipeline's S3 source action refuses a
- # bucket without versioning. This is also what makes empty_bucket.py
- # necessary — `aws s3 rm --recursive` leaves every noncurrent version and
- # delete marker behind, and delete-stack then fails on a bucket the
- # operator believes is empty.
- VersioningConfiguration:
- Status: Enabled
- BucketEncryption:
- ServerSideEncryptionConfiguration:
- - ServerSideEncryptionByDefault:
- SSEAlgorithm: 'AES256'
- PublicAccessBlockConfiguration:
- BlockPublicAcls: true
- BlockPublicPolicy: true
- IgnorePublicAcls: true
- RestrictPublicBuckets: true
- LifecycleConfiguration:
- Rules:
- - Id: 'ExpireSupersededArchives'
- Status: Enabled
- Prefix: 'source/'
- NoncurrentVersionExpirationInDays: !Ref SourceVersionRetentionDays
- AbortIncompleteMultipartUpload:
- DaysAfterInitiation: 7
- - Id: 'ExpireRunArtifacts'
- Status: Enabled
- Prefix: 'artifacts/'
- ExpirationInDays: !Ref ArtifactRetentionDays
- NoncurrentVersionExpirationInDays: 7
- AbortIncompleteMultipartUpload:
- DaysAfterInitiation: 7
-
- SourceBucketPolicy:
- Type: AWS::S3::BucketPolicy
- Properties:
- Bucket: !Ref SourceBucket
- PolicyDocument:
- Version: '2012-10-17'
- Statement:
- - Sid: 'DenyInsecureTransport'
- Effect: Deny
- Principal: '*'
- Action: 's3:*'
- Resource:
- - !GetAtt SourceBucket.Arn
- - !Sub '${SourceBucket.Arn}/*'
- Condition:
- Bool:
- 'aws:SecureTransport': false
-
- # ── The pipeline's own role: deliberately narrow ────────────────────────────
- # It orchestrates and nothing else. Keeping it narrow is what confines the broad
- # grant below to exactly one role. RoleName is omitted so only CAPABILITY_IAM is
- # needed to deploy this stack.
- PipelineRole:
- Type: AWS::IAM::Role
- Properties:
- Description: 'Orchestrates the integration-test pipeline. Starts builds; reads and writes this stack bucket.'
- AssumeRolePolicyDocument:
- Version: '2012-10-17'
- Statement:
- - Effect: Allow
- Principal:
- Service: 'codepipeline.amazonaws.com'
- Action: 'sts:AssumeRole'
- Policies:
- - PolicyName: 'orchestrate'
- PolicyDocument:
- Version: '2012-10-17'
- Statement:
- - Sid: 'SourceAndArtifacts'
- Effect: Allow
- Action:
- - 's3:GetObject'
- - 's3:GetObjectVersion'
- - 's3:PutObject'
- - 's3:GetBucketVersioning'
- Resource:
- - !GetAtt SourceBucket.Arn
- - !Sub '${SourceBucket.Arn}/*'
- # The wildcard is on the PROJECT NAME, not the account: a second
- # test project belonging to this stack can be added later with no
- # role change, and nothing outside this stack's name is reachable.
- - Sid: 'StartThisStacksBuilds'
- Effect: Allow
- Action:
- - 'codebuild:StartBuild'
- - 'codebuild:BatchGetBuilds'
- Resource: !Sub 'arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${AWS::StackName}-*'
-
- # ── The build role: broad, by decision ──────────────────────────────────────
- # AdministratorAccess. The run deploys the seven-stack IPA lifecycle and then
- # deletes it, including IAM roles with explicit names under
- # CAPABILITY_NAMED_IAM, so an enumerated policy would be discovered to be
- # incomplete by a billable run failing halfway through.
- #
- # There is NO permissions boundary and NO session cap. Do not read the narrow
- # PipelineRole above as evidence that this one is also constrained.
- CodeBuildRole:
- Type: AWS::IAM::Role
- Properties:
- Description: 'Runs the integration test. Holds AdministratorAccess because the test deploys and deletes arbitrary infrastructure.'
- AssumeRolePolicyDocument:
- Version: '2012-10-17'
- Statement:
- - Effect: Allow
- Principal:
- Service: 'codebuild.amazonaws.com'
- Action: 'sts:AssumeRole'
- ManagedPolicyArns:
- - !Sub 'arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess'
-
- BuildLogGroup:
- Type: AWS::Logs::LogGroup
- Properties:
- LogGroupName: !Sub '/aws/codebuild/${AWS::StackName}-lifecycle'
- RetentionInDays: 30
-
- LifecycleTestProject:
- Type: AWS::CodeBuild::Project
- Properties:
- # Derived from the stack name, so it stays stable and readable without
- # needing CAPABILITY_NAMED_IAM anywhere. ../credential-vendor/ scopes its
- # read permissions to '${PipelineName}-*', which depends on this shape.
- Name: !Sub '${AWS::StackName}-lifecycle'
- Description: 'Runs internal/integration-tests test-ci against real AWS infrastructure.'
- ServiceRole: !GetAtt CodeBuildRole.Arn
- TimeoutInMinutes: !Ref BuildTimeoutMinutes
- # A run that cannot start is abandoned rather than queued indefinitely.
- QueuedTimeoutInMinutes: 30
- Artifacts:
- Type: CODEPIPELINE
- Environment:
- Image: !Ref BuildImage
- Type: 'LINUX_CONTAINER'
- ComputeType: !Ref ComputeType
- # The run really does `docker build` and push to ECR, so a daemon is
- # required for the run to SUCCEED rather than merely to pass preflight.
- PrivilegedMode: true
- EnvironmentVariables:
- - Name: 'IPA_IT_REGION'
- Value: !Ref 'AWS::Region'
- # A profile name, not credentials — and NOT how the harness
- # authenticates. Every AWS call the harness makes names no profile at
- # all; credentials come from this container's own role.
- #
- # This exists for the GENERATED artifacts. The agent writes
- # AWS_PROFILE into the driven project's .env (four shipped skills STOP
- # without that key), and the generated Makefiles then pass
- # `--profile ipa-it` to the AWS CLI. See the install phase for why a
- # profile that exists carrying no credentials is the right shape.
- - Name: 'AWS_PROFILE'
- Value: 'ipa-it'
- - Name: 'IPA_IT_DRIVER_MODEL'
- Value: !Ref DriverModelId
- - Name: 'IPA_IT_WORKER_MODEL'
- Value: !Ref WorkerModelId
- - Name: 'IPA_IT_MAX_BUDGET_USD'
- Value: !Ref MaxBudgetUsd
- - Name: 'IPA_IT_FULL_MAX_BUDGET_USD'
- Value: !Ref FullMaxBudgetUsd
- - Name: 'IPA_IT_SCENARIO'
- Value: !Ref Scenario
- - Name: 'CLAUDE_CODE_USE_BEDROCK'
- Value: '1'
- # DELIBERATELY ABSENT: IPA_IT_TEARDOWN and IPA_IT_STRICT_PREFLIGHT.
- #
- # Both are set INLINE by the recipes that need them, and nowhere else:
- # internal/integration-tests/Makefile:119 (teardown-namespace),
- # internal/integration-tests/compose/Makefile:71 (test-ci), and
- # internal/integration-tests/deploy/Makefile:81,94 (test-teardown,
- # test-ci). Declaring either here
- # would make it ambient across every phase of every build, which is
- # precisely the "persists invisibly" case the two-part teardown opt-in
- # exists to prevent — and it would defeat that mechanism SILENTLY,
- # because the invocation that needs it still works.
- LogsConfig:
- CloudWatchLogs:
- Status: ENABLED
- GroupName: !Ref BuildLogGroup
- Source:
- Type: CODEPIPELINE
- # A PLAIN LITERAL BLOCK, deliberately NOT under !Sub. Every parameter
- # arrives through EnvironmentVariables above instead, so no ${...} in a
- # shell command can be mistaken for a CloudFormation substitution — the
- # failure mode that deploys cleanly and then runs a mangled command.
- BuildSpec: |
- version: 0.2
-
- phases:
- install:
- # If this pairing fails with "Unknown runtime version", it must move
- # together with the BuildImage parameter. uv downloads its own
- # Python when the image's does not satisfy >=3.12, so the python
- # pin here is belt and uv is braces; the nodejs pin is not
- # recoverable that way.
- #
- # QUOTED. Unquoted, these are YAML NUMBERS, and `python: 3.10`
- # would parse as the float 3.1 — a version that does not exist,
- # reported as an unknown runtime with a number nobody typed. 3.12
- # happens to survive unquoted; the next edit might not.
- runtime-versions:
- python: "3.12"
- nodejs: "22"
- commands:
- # A profile section defining NO credentials still resolves them.
- # botocore drops only env_provider when a profile is named
- # explicitly and KEEPS container_provider, which reads
- # CodeBuild's own credentials. That is what lets the harness keep
- # requiring a NAMED profile (preflight.py:161 constructs
- # boto3.Session(profile_name=...) unconditionally) with zero
- # changes. The profile must EXIST, though — `--profile `
- # is a hard failure, not a fall-through.
- - aws configure set region "$IPA_IT_REGION" --profile ipa-it
- - pip install --no-cache-dir uv
- # The archive MUST carry .git. workspace.py copies the working
- # tree via `git ls-files` and reads HEAD and the dirty flag
- # (workspace.py:91-101) with check=True, so a .git-less archive
- # fails inside a fixture with a CalledProcessError rather than
- # here with a cause. archive.py builds it correctly; this is the
- # assertion that catches an archive built any other way.
- - |
- if [ ! -d .git ]; then
- echo "FATAL: this archive has no .git directory."
- echo " workspace.py copies the working tree with 'git ls-files' and"
- echo " records HEAD, so the harness cannot run without it."
- echo " Build the archive with internal/infra/codepipeline/archive.py."
- exit 1
- fi
- - git rev-parse HEAD
- # Name what is being tested, and say so when it is not a commit
- # anyone else has. A dirty archive is legitimate — it is how a
- # maintainer tests an uncommitted skill edit remotely — but a
- # green run against a tree nobody has is worse than a red one,
- # because it is believed.
- - |
- if [ -n "$(git status --porcelain)" ]; then
- echo "WARNING: built from a DIRTY working tree — the commit above does"
- echo " not fully describe what is running."
- git status --porcelain | head -40
- fi
- # ServerVersion is a SERVER-only field. `docker info` exits 0 with
- # the daemon down, so probing the exit code reports a working
- # daemon that is not there and the failure resurfaces twenty
- # minutes later inside a container build.
- - |
- if ! docker info --format '{{.ServerVersion}}' >/dev/null 2>&1; then
- echo "Starting dockerd..."
- nohup dockerd --host=unix:///var/run/docker.sock >/tmp/dockerd.log 2>&1 &
- timeout 60 sh -c 'until docker info --format "{{.ServerVersion}}" >/dev/null 2>&1; do sleep 1; done'
- fi
- - |
- docker info --format '{{.ServerVersion}}'
- # A FILE, not an exported variable. Buildspec phases do not share
- # shell state, and post_build must know the namespace even when
- # the build phase died early — which is the case that most needs
- # tearing down.
- #
- # 'it' + 10 hex characters is 12, which is exactly
- # answers.NS_MAX_LEN and matches answers.make_namespace's own
- # shape. A longer namespace is rejected at the source.
- - printf 'it%s' "$(openssl rand -hex 5)" > /tmp/ipa_it_namespace
- # A BLOCK, for a one-liner, because the message contains ": ".
- # An unquoted command carrying colon-space is a YAML mapping, not
- # a string, and CodeBuild rejects the whole buildspec with
- # YAML_FILE_ERROR "Expected Commands[n] to be of string type".
- # Neither this template's own validation nor ASH sees inside a
- # block scalar, so the failure only appears in DOWNLOAD_SOURCE.
- - |
- echo "Run namespace: $(cat /tmp/ipa_it_namespace)"
- # Install the harness WITH its optional extra. `uv run` on its own
- # syncs only the default dependencies and would REMOVE
- # claude-agent-sdk, so every later invocation passes
- # --all-extras too. Doing it here as well means a broken
- # environment fails in install, in seconds, rather than after the
- # first fixture has already deployed something.
- - uv sync --all-extras --directory internal/integration-tests
- # The bundled agent binary. Do NOT `npm install` the Claude CLI
- # as a fallback: _find_cli prefers the bundle over PATH
- # (subprocess_cli.py:249-252), so an npm install would be
- # ignored while appearing to fix the problem.
- - |
- uv run --all-extras --directory internal/integration-tests python - <<'PY'
- import pathlib
- import sys
-
- import claude_agent_sdk
-
- binary = pathlib.Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude"
- if not binary.is_file():
- sys.exit(f"bundled claude binary missing at {binary}")
- print(f"bundled claude binary present: {binary}")
- PY
- # Prove the property THE HARNESS ACTUALLY DEPENDS ON: that a
- # session naming NO profile resolves this container's own
- # credentials. The previous form built the session from
- # IPA_IT_PROFILE, which asserted something nothing depends on —
- # it could pass where the harness fails and fail where the
- # harness works, which is worse than no check because it is
- # believed.
- #
- # The resolution METHOD is printed because resolving is not
- # enough on its own: a build answering from a stray long-lived
- # environment key has resolved credentials successfully and is
- # still misconfigured. Expect 'container-role' here.
- - |
- uv run --all-extras --directory internal/integration-tests python - <<'PY'
- import os
-
- import boto3
-
- session = boto3.Session(region_name=os.environ["IPA_IT_REGION"])
- identity = session.client("sts").get_caller_identity()
- creds = session.get_credentials()
- method = getattr(creds, "method", "unresolved")
- print(f"no profile named; resolved by {method!r}")
- print(f"account {identity['Account']} as {identity['Arn']}")
- PY
- # Every remaining prerequisite, from the harness's own probe
- # table, printed as its nine-row report. --probes-only skips the
- # Cognito blocking check, which deploys a user pool: the run
- # itself deploys one, so paying for a second here buys nothing.
- # It reports rather than gates; test-ci's own
- # IPA_IT_STRICT_PREFLIGHT=1 is what turns an unmet prerequisite
- # into a failure instead of a silent skip.
- - uv run --all-extras --directory internal/integration-tests python -m harness.preflight --probes-only
- build:
- commands:
- - export IPA_IT_NAMESPACE="$(cat /tmp/ipa_it_namespace)"
- # SCENARIO selects which scenario folder test-ci delegates to.
- # A command-line variable propagates to sub-makes through
- # MAKEFLAGS, so it reaches the scenario's own Makefile without
- # the umbrella forwarding it explicitly.
- #
- # PYTEST is overridden so the harness's invocation gains
- # --all-extras. It MUST keep `python -m pytest`: only `-m`
- # places the working directory on sys.path, which is what makes
- # `harness` importable with no build backend. With a bare
- # `pytest` console script every test module fails with
- # `ModuleNotFoundError: No module named 'harness'`.
- - |
- make -C internal/integration-tests test-ci \
- SCENARIO="$IPA_IT_SCENARIO" \
- PYTEST='uv run --all-extras python -m pytest'
- post_build:
- commands:
- # UNCONDITIONAL. The case that most needs teardown is the one
- # where the build phase FAILED, so this must not be guarded on
- # CODEBUILD_BUILD_SUCCEEDING. test-ci already tears down inside
- # its own pytest session; this is what covers the session dying
- # before it got there.
- #
- # Scoped to this run's namespace, so a maintainer's standing
- # local deployment in the same account is untouched.
- - export IPA_IT_NAMESPACE="$(cat /tmp/ipa_it_namespace)"
- - |
- echo "Tearing down namespace: $IPA_IT_NAMESPACE"
- - |
- make -C internal/integration-tests teardown-namespace \
- NAMESPACE="$IPA_IT_NAMESPACE" \
- PYTHON='uv run --all-extras python' \
- || echo 'TEARDOWN REPORTED FAILURES — run "make -C internal/integration-tests list-stacks" before the next run.'
- # Always print what is left, pass or fail. A run that tore
- # everything down and a run that left seven billable stacks
- # standing are otherwise indistinguishable from the log.
- - |
- make -C internal/integration-tests list-stacks \
- PYTHON='uv run --all-extras python' || true
-
- # A GLOB, because each scenario names its own report
- # (junit-compose.xml, junit-deploy.xml) so two runs' results stay
- # distinguishable. One fixed name would collect nothing whenever the
- # other scenario ran — and a build that collected nothing reports NO
- # TESTS rather than a failure, which reads as a pass.
- #
- # Not built from the Scenario parameter with !Sub: this buildspec is a
- # plain literal block, deliberately NOT under !Sub, so every $VAR in it
- # reaches the shell. Introducing !Sub for one filename would require
- # escaping every existing $ in the block, to gain nothing a glob does
- # not already do.
- reports:
- integration:
- files:
- - 'junit-*.xml'
- base-directory: 'internal/integration-tests'
- file-format: 'JUNITXML'
-
- artifacts:
- files:
- - 'internal/integration-tests/junit-*.xml'
- # Both land at the HARNESS ROOT, not inside harness/ — standing.HERE
- # and the transcript fixture are anchored one level above the
- # package precisely so these two paths stay true after the move.
- - 'internal/integration-tests/transcript-*.jsonl'
- - 'internal/integration-tests/standing-resources-*.md'
- name: 'run-$CODEBUILD_BUILD_NUMBER'
-
- Pipeline:
- Type: AWS::CodePipeline::Pipeline
- Properties:
- Name: !Sub '${AWS::StackName}'
- RoleArn: !GetAtt PipelineRole.Arn
- ArtifactStore:
- Type: S3
- Location: !Ref SourceBucket
- Stages:
- - Name: Source
- Actions:
- - Name: Archive
- ActionTypeId:
- Category: Source
- Owner: AWS
- Provider: S3
- Version: '1'
- Configuration:
- S3Bucket: !Ref SourceBucket
- S3ObjectKey: !Ref SourceObjectKey
- # FALSE, and this is the point: uploading an archive must start
- # NOTHING. A billable run happens only when someone asks for one.
- PollForSourceChanges: false
- OutputArtifacts:
- - Name: SourceArtifact
- RunOrder: 1
- - Name: Test
- Actions:
- # RunOrder 1 so a second test project can be added beside this one
- # and run in parallel, needing no change to PipelineRole.
- - Name: LifecycleTest
- ActionTypeId:
- Category: Build
- Owner: AWS
- Provider: CodeBuild
- Version: '1'
- Configuration:
- ProjectName: !Ref LifecycleTestProject
- InputArtifacts:
- - Name: SourceArtifact
- OutputArtifacts:
- - Name: LifecycleTestOutput
- RunOrder: 1
-
-Outputs:
- SourceBucketName:
- Description: 'Bucket the trigger uploads the source archive to.'
- Value: !Ref SourceBucket
- Export:
- Name: !Sub '${AWS::StackName}-SourceBucketName'
-
- SourceObjectKey:
- Description: 'Key the trigger uploads the source archive to.'
- Value: !Ref SourceObjectKey
- Export:
- Name: !Sub '${AWS::StackName}-SourceObjectKey'
-
- PipelineName:
- Description: 'Pipeline to start. Uploading an archive does not start it.'
- Value: !Ref Pipeline
- Export:
- Name: !Sub '${AWS::StackName}-PipelineName'
-
- CodeBuildProjectName:
- Description: 'Build project whose logs and reports carry the run result.'
- Value: !Ref LifecycleTestProject
- Export:
- Name: !Sub '${AWS::StackName}-CodeBuildProjectName'
diff --git a/internal/infra/credential-vendor/Makefile b/internal/infra/credential-vendor/Makefile
deleted file mode 100644
index 3a7b66a..0000000
--- a/internal/infra/credential-vendor/Makefile
+++ /dev/null
@@ -1,243 +0,0 @@
-# Credential vendor role — the one-time, hand-run deploy of the IAM role this
-# repository's GitLab CI jobs assume. Internal maintainer tooling; not shipped.
-#
-# A bare `make` prints help and creates nothing. Both targets that change an AWS
-# account require CONFIRM=1, and WITHOUT it they print exactly what they would
-# do — against which account, read from sts:get-caller-identity rather than from
-# a variable — and stop. That is the guard that matters here: the risk is not a
-# malformed template, it is landing the role in the wrong account. `make deploy`
-# with no CONFIRM is therefore the dry run.
-#
-# This mirrors ../codepipeline/Makefile deliberately, including the target names.
-# Two sibling directories under internal/infra/ disagreeing about which
-# operations need confirmation, or about whether the destructive one is called
-# `delete` or `destroy`, is exactly the drift that gets a guard skipped.
-#
-# This wraps commands README.md also documents directly. Windows has no `make` —
-# use those.
-
-.DEFAULT_GOAL := help
-.PHONY: help validate deploy outputs status destroy
-
-HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
-TEMPLATE := $(HERE)credential-vendor.yml
-
-STACK_NAME ?= ipa-gitlab-ci-role
-ROLE_NAME ?= InnovationPatternsGitLabCI
-
-# The pipeline stack this role is allowed to start. Its outputs supply
-# SOURCE_BUCKET below when that is not passed explicitly.
-PIPELINE_STACK ?= ipa-integration-test-pipeline
-SOURCE_OBJECT_KEY ?= source/source.zip
-
-# Empty by default and resolved from the pipeline stack inside the deploy recipe.
-# Not a $(shell ...) at parse time: that would call AWS on every invocation of
-# this Makefile, including `make help`, which must cost nothing.
-SOURCE_BUCKET ?=
-
-# `aws configure list-profiles` is the check rather than grepping ~/.aws/config,
-# because it reads both config and credentials files and knows their formats.
-# grep -Fx so the profile's '+' is matched literally, and so a longer profile
-# that merely CONTAINS this name does not count as a match.
-#
-# The existence check is the part that matters. A maintainer-specific default is
-# right for the person who wrote it and wrong on a CloudDesktop or EC2 host using
-# an instance role — and `--profile ` is a HARD failure there ("could
-# not be found"), not a fall-through to ambient credentials. So the default
-# applies only where it is real.
-AWS_DEFAULT_PROFILE_NAME := vincilb+ip-dev-Admin
-AWS_PROFILE ?= $(shell aws configure list-profiles 2>/dev/null \
- | grep -Fxq "$(AWS_DEFAULT_PROFILE_NAME)" \
- && echo "$(AWS_DEFAULT_PROFILE_NAME)")
-
-# DEFAULTED, unlike the reference implementation's credential-vendor Makefile,
-# and the difference is deliberate. That stack creates a role with only a global
-# managed policy attached, so omitting the region is right there. This role's
-# inline policy names REGIONAL ARNs — the pipeline, the build project, the log
-# group — so a stack landing in whatever region a profile happens to default to
-# would grant nothing usable, and would be discovered only by a CI job being
-# denied.
-#
-# us-east-1 matches ../codepipeline/Makefile and the region .gitlab-ci.yml's
-# credentials block already sets.
-#
-# `:=` rather than `?=`, for the reason recorded in ../codepipeline/Makefile: `?=`
-# yields to an EXPORTED variable, and a shell exporting AWS_REGION=us-west-2
-# silently captured it. A command-line `make AWS_REGION= ` still wins.
-AWS_REGION := us-east-1
-
-# Passed as flags, never exported. A maintainer deploys this by hand under a
-# named profile, but exporting AWS_PROFILE leaks into everything else the shell
-# later runs, and an empty default keeps these same targets usable under vended
-# credentials.
-PROFILE_FLAG := $(if $(AWS_PROFILE),--profile "$(AWS_PROFILE)",)
-AWS := aws $(PROFILE_FLAG) --region "$(AWS_REGION)"
-
-# Derive the identity values from the remote rather than hardcoding them. A fork,
-# a renamed project, or a moved group makes literals wrong, and a wrong group
-# path fails CLOSED at assume time — denied, with nothing naming the path as the
-# cause. Both remote forms are handled:
-# git@ssh.code.aws.dev:proserve/genaiid/other/candidate-reusable-assets/innovation-patterns.git
-# https://code.aws.dev/proserve/genaiid/other/candidate-reusable-assets/innovation-patterns.git
-# by stripping scheme+host, then user@host:, then the .git suffix. The group is
-# everything but the last path segment — every subgroup included, which is the
-# part that must not be shortened.
-#
-# sed uses | as its delimiter, not the usual #, because Make treats # as the
-# start of a comment even inside $(shell ...) — with # here the whole call is
-# swallowed and Make dies on an unterminated function.
-REMOTE_PATH := $(shell git -C "$(HERE)" remote get-url origin 2>/dev/null \
- | sed -e 's|^[a-z+]*://[^/]*/||' -e 's|^.*:||' -e 's|\.git$$||')
-GITLAB_GROUP ?= $(patsubst %/,%,$(dir $(REMOTE_PATH)))
-GITLAB_PROJECT ?= $(notdir $(REMOTE_PATH))
-
-help: ## Show this help
- @echo "Credential vendor role — one-time manual deploy"
- @echo ""
- @echo "Targets that CHANGE an AWS account (both need CONFIRM=1):"
- @echo " deploy Create or update the role. Without CONFIRM=1 this is a DRY"
- @echo " RUN: it prints the target account and parameters, then stops."
- @echo " destroy Delete the stack and the role. Any CI job configured with"
- @echo " its ARN starts failing."
- @echo ""
- @echo "Read-only targets (create nothing, cost nothing):"
- @echo " validate Check the template is structurally valid"
- @echo " outputs Print the stack's outputs, including the role ARN"
- @echo " status Print the stack's current status"
- @echo " help Show this message (default)"
- @echo ""
- @echo "Derived from 'git remote get-url origin':"
- @echo " GitLabGroupName $(if $(GITLAB_GROUP),$(GITLAB_GROUP),)"
- @echo " GitLabProjectName $(if $(GITLAB_PROJECT),$(GITLAB_PROJECT),)"
- @echo " Override either with 'make GITLAB_GROUP= ... '."
- @echo ""
- @echo "Other variables:"
- @echo " STACK_NAME=$(STACK_NAME) ROLE_NAME=$(ROLE_NAME)"
- @echo " AWS_REGION=$(AWS_REGION) PIPELINE_STACK=$(PIPELINE_STACK)"
- @echo " SOURCE_BUCKET=$(if $(SOURCE_BUCKET),$(SOURCE_BUCKET),)"
- @echo ""
- @echo "AWS profile: $(if $(AWS_PROFILE),$(AWS_PROFILE),)"
- @echo " Defaults to $(AWS_DEFAULT_PROFILE_NAME) when that profile exists on"
- @echo " this machine; otherwise no --profile is passed. Either way, 'make"
- @echo " deploy' prints the resolved account before doing anything — that line"
- @echo " is the one to check, not this one."
- @echo ""
- @echo "ORDER: deploy ../codepipeline FIRST. This role's policy names that"
- @echo " stack's bucket and pipeline, and the bucket name is generated."
- @echo ""
- @echo "This role is deployed ONCE, BY HAND, per account. No CI job deploys it."
- @echo "It holds NO deploy permissions — see README.md for what it can do and"
- @echo "for why that is still indirect administrator access."
-
-validate: ## Check the template is structurally valid (creates nothing)
- @$(AWS) cloudformation validate-template \
- --template-body "file://$(TEMPLATE)" --output table
-
-# ONE shell block, continuations included, so the dry-run `exit 0` ends the whole
-# target. Do NOT split the post-deploy reporting into separate recipe lines:
-# `exit 0` only leaves its own line's subshell, so make would carry on and run
-# `outputs` against a stack the dry run never created — which fails with a bare
-# DescribeStacks ValidationError and makes a successful dry run look broken.
-deploy: ## Create/update the role. Needs CONFIRM=1. CREATES AN IAM ROLE.
- @set -e; \
- if [ -z "$(GITLAB_GROUP)" ] || [ -z "$(GITLAB_PROJECT)" ]; then \
- echo "ERROR: could not derive the GitLab group and project from the origin remote."; \
- echo " Pass them explicitly — the group path must include every subgroup:"; \
- echo " make deploy GITLAB_GROUP= GITLAB_PROJECT= CONFIRM=1"; \
- exit 1; \
- fi; \
- echo "Deploying INTO this account — check it before confirming:"; \
- $(AWS) sts get-caller-identity \
- --query "join(' ', [Account, Arn])" --output text \
- || { echo "ERROR: no usable AWS credentials. Set AWS_PROFILE or refresh them."; exit 1; }; \
- bucket="$(SOURCE_BUCKET)"; \
- if [ -z "$$bucket" ]; then \
- bucket="$$($(AWS) cloudformation describe-stacks --stack-name "$(PIPELINE_STACK)" \
- --query "Stacks[0].Outputs[?OutputKey=='SourceBucketName'].OutputValue | [0]" \
- --output text 2>/dev/null || true)"; \
- fi; \
- if [ -z "$$bucket" ] || [ "$$bucket" = "None" ]; then \
- echo ""; \
- echo "ERROR: could not resolve the source bucket."; \
- echo " It is read from the '$(PIPELINE_STACK)' stack's SourceBucketName"; \
- echo " output, which means that stack must exist in this account and region."; \
- echo " Deploy it first: make -C ../codepipeline deploy CONFIRM=1"; \
- echo " Or pass it: make deploy SOURCE_BUCKET= CONFIRM=1"; \
- exit 1; \
- fi; \
- echo ""; \
- echo "Stack: $(STACK_NAME)"; \
- echo "Region: $(AWS_REGION)"; \
- echo "RoleName: $(ROLE_NAME)"; \
- echo "GitLabGroupName: $(GITLAB_GROUP)"; \
- echo "GitLabProjectName: $(GITLAB_PROJECT)"; \
- echo "PipelineName: $(PIPELINE_STACK)"; \
- echo "SourceBucketName: $$bucket"; \
- echo "SourceObjectKey: $(SOURCE_OBJECT_KEY)"; \
- echo ""; \
- echo "Creates an IAM role assumable ONLY by the code.aws.dev credential"; \
- echo "vendor's hop-2 role, and only for the group and project above. It can"; \
- echo "upload one S3 key, start that one pipeline, and read the result. It"; \
- echo "holds no deploy permissions — but the code it uploads runs under the"; \
- echo "pipeline's AdministratorAccess build role, so this is still INDIRECT"; \
- echo "administrator access. A wrong group or project is the whole security"; \
- echo "surface: there is no permissions boundary and no trust expiry."; \
- if [ "$(CONFIRM)" != "1" ]; then \
- echo ""; \
- echo "DRY RUN — nothing was deployed. To create it for real:"; \
- echo " make deploy CONFIRM=1"; \
- exit 0; \
- fi; \
- echo ""; \
- $(AWS) cloudformation deploy \
- --template-file "$(TEMPLATE)" \
- --stack-name "$(STACK_NAME)" \
- --parameter-overrides \
- RoleName="$(ROLE_NAME)" \
- GitLabGroupName="$(GITLAB_GROUP)" \
- GitLabProjectName="$(GITLAB_PROJECT)" \
- PipelineName="$(PIPELINE_STACK)" \
- SourceBucketName="$$bucket" \
- SourceObjectKey="$(SOURCE_OBJECT_KEY)" \
- --capabilities CAPABILITY_NAMED_IAM; \
- echo ""; \
- $(MAKE) --no-print-directory outputs; \
- echo ""; \
- echo "Next: set GitLabRoleArn as the AWS_CREDS_TARGET_ROLE CI/CD variable and"; \
- echo "uncomment the variables block in .gitlab-ci.yml (lines 32-43)."
-
-outputs: ## Print the stack's outputs, including the role ARN
- @$(AWS) cloudformation describe-stacks --stack-name "$(STACK_NAME)" \
- --query "Stacks[0].Outputs" --output table \
- || { echo "No stack named '$(STACK_NAME)' in $(AWS_REGION)."; \
- echo "Deploy it with: make deploy CONFIRM=1"; exit 1; }
- @echo "GitLabRoleArn is the value for the AWS_CREDS_TARGET_ROLE CI/CD variable."
-
-status: ## Print the stack's current status
- @$(AWS) cloudformation describe-stacks --stack-name "$(STACK_NAME)" \
- --query "Stacks[0].StackStatus" --output text \
- || { echo "No stack named '$(STACK_NAME)' in $(AWS_REGION)."; exit 1; }
-
-destroy: ## Delete the stack and the role. Needs CONFIRM=1. DELETES AN IAM ROLE.
- @set -e; \
- echo "Deleting stack '$(STACK_NAME)' FROM this account:"; \
- $(AWS) sts get-caller-identity \
- --query "join(' ', [Account, Arn])" --output text \
- || { echo "ERROR: no usable AWS credentials. Set AWS_PROFILE or refresh them."; exit 1; }; \
- echo ""; \
- echo "This DELETES the IAM role. Any CI job configured with its ARN starts"; \
- echo "failing, and re-creating it does not restore the old sessions."; \
- echo ""; \
- echo "It does NOT touch the pipeline. Delete that separately with"; \
- echo "'make -C ../codepipeline destroy CONFIRM=1'."; \
- if [ "$(CONFIRM)" != "1" ]; then \
- echo ""; \
- echo "DRY RUN — nothing was deleted. To delete it for real:"; \
- echo " make destroy CONFIRM=1"; \
- exit 0; \
- fi; \
- echo ""; \
- $(AWS) cloudformation delete-stack --stack-name "$(STACK_NAME)"; \
- echo "Delete requested. Waiting for it to finish..."; \
- $(AWS) cloudformation wait stack-delete-complete --stack-name "$(STACK_NAME)"; \
- echo "Deleted."
diff --git a/internal/infra/credential-vendor/README.md b/internal/infra/credential-vendor/README.md
deleted file mode 100644
index 73e43c5..0000000
--- a/internal/infra/credential-vendor/README.md
+++ /dev/null
@@ -1,153 +0,0 @@
-# Credential vendor role
-
-The IAM role this repository's GitLab CI jobs assume to reach AWS with no long-lived key.
-Deployed once, by hand, per target account. Internal maintainer tooling — not shipped.
-
-Its only purpose is to let a CI job do what `make -C ../codepipeline trigger` does: upload
-a source archive, start one pipeline execution, and read the result.
-
-## This may already be unnecessary
-
-`.gitlab-ci.yml:32-43` carries a commented-out job documenting the mechanism and naming a
-role that **already exists**:
-
-```yaml
-AWS_CREDS_TARGET_ROLE: arn:aws:iam::369530416671:role/ip-dev-sdlc-GitLab
-```
-
-That role was provisioned by the SDLC account vending, not by this repository. If it can
-already start the pipeline, this stack is redundant — check its policy before deploying a
-second role. This template exists for the case where it cannot, or where the pipeline
-lives in a different account.
-
-## How the credentials arrive (two hops, two accounts)
-
-| Hop | What happens |
-|---|---|
-| 1 | The vendor reads the job's `CI_JOB_TOKEN` and assumes a role in jump account `979517299116`, which attaches the job's GitLab metadata as **principal tags** — derived from the real running job, not supplied by the caller. |
-| 2 | It presents `arn:aws:iam::352364310414:role/prod_jump_role` to **this** role, with a `RoleSessionName` of `-GitLab` for CloudTrail. |
-
-Only the hop-2 role is a trust-policy principal. Naming the hop-1 account instead produces
-a role that deploys cleanly, reports success, and fails only when a CI job first tries to
-assume it.
-
-The internal `code.aws.dev` runner does this automatically when `AWS_CREDS_TARGET_ROLE` is
-set. No `id_tokens` block and no explicit assume-role step are needed.
-
-## What this role can and cannot do
-
-**Can:** `s3:PutObject` on exactly one key; `s3:GetBucketVersioning`;
-`codepipeline:StartPipelineExecution` and three read actions on one pipeline;
-`codebuild:BatchGetBuilds`/`BatchGetReports`/`DescribeTestCases` and three `logs:` read
-actions, scoped by the pipeline stack's naming convention.
-
-**Cannot:** deploy anything. Create, modify or delete any resource. Read any other bucket.
-Start a CodeBuild project directly — deliberately omitted, so a run must enter through the
-pipeline and the archive under test is the one recorded in the pipeline execution.
-
-**This is deliberately narrower than the reference implementation**
-(`launchbridge/internal/infra/credential-vendor`), which attaches `AdministratorAccess`
-because CI deploys stacks directly there. Here the privilege that runs the test belongs to
-the pipeline's own build role, so this role does not need any.
-
-### But it is still indirect administrator access
-
-Do not read the narrow policy as a containment boundary. The pipeline's `CodeBuildRole`
-holds `AdministratorAccess`, and it runs **whatever code this role uploaded**. So:
-
-> Write access to the source key is write access to code that runs as an administrator in
-> the target account.
-
-Narrowing this role removes one path to administrator — a stolen CI token cannot call
-`iam:CreateUser` directly — not the path. What it buys is a much smaller blast radius for
-everything *other* than "run the test": no data exfiltration from other buckets, no
-resource deletion, no privilege escalation without going through a pipeline execution that
-CloudTrail records. That is worth having, and it is not the same as containment.
-
-## Two conditions are the entire control
-
-```yaml
-StringEquals:
- 'aws:PrincipalTag/GitLab:Group':
- 'aws:PrincipalTag/GitLab:Project':
-```
-
-There is no permissions boundary, no trust expiry and no session cap. A wrong group or
-project is the whole security surface.
-
-Two things that read as bugs and are not:
-
-- **`PrincipalTag`, not `RequestTag`.** Hop 1 already attached this metadata as principal
- tags on the caller, derived from the real running job, so by hop 2 it cannot be forged.
- Gating on `RequestTag` would test values the caller supplies with its own request —
- i.e. nothing — and would break vending outright.
-- **One statement, carrying the principal, both actions and both conditions together.**
- Splitting `sts:AssumeRole` and `sts:TagSession` into two statements that each repeat the
- condition block is safe only while the repetition stays correct. Any statement that
- permits assumption *without* the conditions fails **open**: it independently allows
- assumption, so the conditions stop constraining anything and the role becomes assumable
- by any project on the instance, with nothing appearing broken. Nothing in this
- repository detects that.
-
-`sts:TagSession` is mandatory. Without it the assumption is denied outright, because the
-vendor tags the session during hop 2.
-
-## Install
-
-Deploy `../codepipeline` **first** — this role's policy names that stack's bucket, and the
-bucket name is CloudFormation-generated.
-
-```bash
-make -C internal/infra/credential-vendor # help
-make -C internal/infra/credential-vendor validate # template check; free
-make -C internal/infra/credential-vendor deploy # DRY RUN: prints the account
-make -C internal/infra/credential-vendor deploy CONFIRM=1
-```
-
-`deploy` derives `GitLabGroupName` and `GitLabProjectName` from `git remote get-url
-origin`, and reads `SourceBucketName` from the pipeline stack. Override any of them:
-
-```bash
-make deploy GITLAB_GROUP= GITLAB_PROJECT= \
- SOURCE_BUCKET= CONFIRM=1
-```
-
-The group path must include **every** subgroup —
-`proserve/genaiid/other/candidate-reusable-assets` for this repository. A partial path
-fails closed: assumption is denied with nothing indicating the path was wrong.
-
-Then set the `GitLabRoleArn` output as the `AWS_CREDS_TARGET_ROLE` CI/CD variable and
-uncomment the variables block in `.gitlab-ci.yml`.
-
-## What silently breaks vending
-
-Each of these deploys cleanly and fails only at assume time, with a denial that does not
-name the cause:
-
-- A group path missing a subgroup.
-- Renaming the project or moving the group without updating the stack.
-- Dropping `sts:TagSession`.
-- Changing the hop-2 principal to the hop-1 account.
-- Deleting and recreating the role under a generated name — the ARN in the CI/CD variable
- becomes stale. This is why `RoleName` is explicit, and why cfn_nag's `W28` warning about
- it is suppressed rather than fixed: the name is the interface.
-
-`AWS_REGION` matters here, unlike in the reference implementation. That template attaches
-only a global managed policy, so region was irrelevant; this one's inline policy names
-**regional** ARNs — the pipeline, the build project, the log group — so a stack deployed
-into the wrong region grants nothing usable, and it would be discovered only by a CI job
-being denied. It is pinned to `us-east-1` with `:=`.
-
-## Verified, and not
-
-**Verified locally:** `aws cloudformation validate-template` passes and reports
-`CAPABILITY_NAMED_IAM` (which is what `deploy` passes); cfn-lint 4.x reports nothing;
-checkov reports 9 passed / 0 failed; cfn_nag reports 0 failures and one warning (`W28`,
-the explicit role name, suppressed with a reason); the group and project derive correctly
-from this repository's remote; the dry run stops without acting and correctly refuses when
-the pipeline stack does not exist.
-
-**Not verified:** the role has never been deployed, and no CI job has ever assumed it. The
-two-hop flow and the principal-tag conditions are transcribed from the reference
-implementation, which measured them against its own group and project — they are not
-measured here.
diff --git a/internal/infra/credential-vendor/credential-vendor.yml b/internal/infra/credential-vendor/credential-vendor.yml
deleted file mode 100644
index 3ce0404..0000000
--- a/internal/infra/credential-vendor/credential-vendor.yml
+++ /dev/null
@@ -1,183 +0,0 @@
-AWSTemplateFormatVersion: '2010-09-09'
-Description: >-
- IAM role assumed by this repository's GitLab CI jobs via the code.aws.dev
- credential vendor, scoped to STARTING a run of the integration-test pipeline
- and reading its result. Deployed once, by hand, per target AWS account.
- Internal maintainer artifact — not shipped to customers. See README.md.
-
-# ─────────────────────────────────────────────────────────────────────────────
-# HOW THE CREDENTIALS ARRIVE (two hops, two accounts)
-#
-# hop 1 the vendor reads the job's CI_JOB_TOKEN and assumes a role in jump
-# account 979517299116, which attaches the job's GitLab metadata as
-# PRINCIPAL TAGS — derived from the real running job, not supplied by
-# the caller.
-# hop 2 it presents arn:aws:iam::352364310414:role/prod_jump_role to THIS
-# role, with a RoleSessionName of -GitLab for CloudTrail.
-#
-# Only the hop-2 role is a trust-policy principal. Naming the hop-1 account here
-# instead produces a role that deploys cleanly, reports success, and fails only
-# when a CI job first tries to assume it.
-#
-# WHY THIS ROLE IS NOT AN ADMINISTRATOR
-#
-# The reference implementation this is modelled on
-# (launchbridge/internal/infra/credential-vendor) attaches AdministratorAccess,
-# because there CI deploys stacks directly. Here it must not: the privilege that
-# runs the integration test belongs to the pipeline's OWN CodeBuild role, in
-# integration-test-pipeline.yml. All this role needs is "put one object, start
-# one pipeline, read its status" — a small, enumerable set that does not chase a
-# moving target, and does not put a second administrator in the account.
-#
-# Read that as the design: a stolen CI token buys the ability to start a test
-# run, not the ability to do anything in the account directly. It is still
-# INDIRECT administrator access, because the code it uploads runs under the
-# CodeBuild role — see the posture section of README.md, which does not pretend
-# otherwise. Narrowing this role removes one path, not the path.
-# ─────────────────────────────────────────────────────────────────────────────
-
-Parameters:
- RoleName:
- Type: String
- Default: 'InnovationPatternsGitLabCI'
- Description: >-
- Name of the created role. Explicit because its ARN goes into a GitLab
- CI/CD variable, so the name must stay stable across stack updates.
-
- GitLabGroupName:
- Type: String
- Description: >-
- Full group path of the repository, INCLUDING every subgroup — e.g.
- proserve/genaiid/other/candidate-reusable-assets. A partial path fails
- CLOSED: assumption is denied with nothing indicating the path was wrong.
-
- GitLabProjectName:
- Type: String
- Default: 'innovation-patterns'
- Description: >-
- Project name of the repository. Not sufficient on its own — project names
- are not unique across groups, which is why the group is also matched.
-
- PipelineName:
- Type: String
- Default: 'ipa-integration-test-pipeline'
- Description: >-
- Pipeline this role may start. Read from the pipeline stack's PipelineName
- output (`make -C ../codepipeline outputs`). Taken as a parameter rather
- than imported, so this stack stays deployable independently of that one.
-
- SourceBucketName:
- Type: String
- Description: >-
- Bucket this role may upload the source archive to. Read from the pipeline
- stack's SourceBucketName output — it is CloudFormation-generated, so it
- cannot be derived from a name and must be passed.
-
- SourceObjectKey:
- Type: String
- Default: 'source/source.zip'
- Description: >-
- The ONE key this role may write. Scoping to the key rather than the bucket
- is what stops a compromised job from filling the bucket, and it must match
- the pipeline's SourceObjectKey — the source action watches only that key.
-
-Resources:
- GitLabRole:
- Type: AWS::IAM::Role
- Properties:
- RoleName: !Ref RoleName
- Description: >-
- Starts integration-test pipeline runs from GitLab CI. Holds no
- deploy permissions of its own.
- AssumeRolePolicyDocument:
- Version: '2012-10-17'
- # ONE statement, carrying the principal, both actions, and both
- # conditions together.
- #
- # Splitting sts:AssumeRole and sts:TagSession into two statements that
- # each repeat the condition block is safe only while the repetition
- # stays correct. Any statement here that permits assumption WITHOUT the
- # conditions fails OPEN — it independently allows assumption, so the
- # conditions stop constraining anything and the role becomes assumable
- # by any project on the instance, with nothing appearing broken.
- # Nothing in this repository detects that. Keep it to one statement.
- Statement:
- - Effect: Allow
- Principal:
- # Hop-2 role of code.aws.dev's credential vendor. Platform
- # documentation: do not change this.
- AWS: 'arn:aws:iam::352364310414:role/prod_jump_role'
- Action:
- - 'sts:AssumeRole'
- # Mandatory. The vendor tags the session with job metadata during
- # hop 2; without this the assumption is denied outright.
- - 'sts:TagSession'
- Condition:
- # PrincipalTag, not RequestTag — this reads as a bug and is not.
- # Hop 1 already attached this metadata as principal tags on the
- # caller, derived from the real running job, so by hop 2 it cannot
- # be forged. Gating on RequestTag would test values the caller
- # supplies with its own request, i.e. nothing, and would break
- # vending outright.
- StringEquals:
- 'aws:PrincipalTag/GitLab:Group': !Ref GitLabGroupName
- 'aws:PrincipalTag/GitLab:Project': !Ref GitLabProjectName
- Policies:
- - PolicyName: 'trigger-integration-test'
- PolicyDocument:
- Version: '2012-10-17'
- Statement:
- # PutObject on ONE key, and GetBucketVersioning because the
- # uploading client reads it to confirm the bucket is versioned
- # before relying on the source action.
- - Sid: 'UploadSourceArchive'
- Effect: Allow
- Action:
- - 's3:PutObject'
- Resource: !Sub 'arn:${AWS::Partition}:s3:::${SourceBucketName}/${SourceObjectKey}'
- - Sid: 'ReadBucketVersioning'
- Effect: Allow
- Action:
- - 's3:GetBucketVersioning'
- Resource: !Sub 'arn:${AWS::Partition}:s3:::${SourceBucketName}'
- # Start a run and read its progress. GetPipelineState and
- # GetPipelineExecution are what a CI job polls to decide whether
- # the run passed; without them a job can start a run and never
- # learn its result, which reports success for a failed test.
- - Sid: 'StartAndWatchThePipeline'
- Effect: Allow
- Action:
- - 'codepipeline:StartPipelineExecution'
- - 'codepipeline:GetPipelineState'
- - 'codepipeline:GetPipelineExecution'
- - 'codepipeline:ListActionExecutions'
- Resource: !Sub 'arn:${AWS::Partition}:codepipeline:${AWS::Region}:${AWS::AccountId}:${PipelineName}'
- # Read the build's logs and reports. Deliberately read-only and
- # deliberately NOT codebuild:StartBuild — a run must enter through
- # the pipeline, so that the source archive under test is the one
- # recorded in the pipeline execution.
- - Sid: 'ReadBuildResults'
- Effect: Allow
- Action:
- - 'codebuild:BatchGetBuilds'
- - 'codebuild:BatchGetReports'
- - 'codebuild:DescribeTestCases'
- Resource: !Sub 'arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${PipelineName}-*'
- # The build's own log group. Scoped by the pipeline stack's naming
- # convention rather than granted account-wide.
- - Sid: 'ReadBuildLogs'
- Effect: Allow
- Action:
- - 'logs:GetLogEvents'
- - 'logs:FilterLogEvents'
- - 'logs:DescribeLogStreams'
- Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/${PipelineName}-*:*'
-
-Outputs:
- GitLabRoleArn:
- Description: 'ARN of the role. This is the value for the AWS_CREDS_TARGET_ROLE CI/CD variable.'
- Value: !GetAtt GitLabRole.Arn
-
- GitLabRoleName:
- Description: 'Name of the created role.'
- Value: !Ref GitLabRole
diff --git a/internal/integration-tests/.gitignore b/internal/integration-tests/.gitignore
deleted file mode 100644
index 50b502d..0000000
--- a/internal/integration-tests/.gitignore
+++ /dev/null
@@ -1,20 +0,0 @@
-# Per-run artifacts. Never committed.
-#
-# The transcript can carry verbatim agent output and account identifiers, and the
-# standing-resource pointer names live billable infrastructure — both describe one
-# machine's one run, so committing either would publish state that is already stale.
-# Findings that must outlive a run go in DECISIONS.md by hand.
-
-# Transcripts land at the harness root; the per-scenario glob covers a scenario that
-# writes its own.
-transcript-*.jsonl
-*/transcript-*.jsonl
-
-# Each scenario names its own JUnit report (junit-compose.xml, junit-deploy.xml), which is
-# also why the pipeline collects junit-*.xml rather than one fixed name.
-junit-*.xml
-
-# UNPREFIXED on purpose. standing.HERE is anchored to the harness ROOT — one level above
-# the harness/ package that writes it — precisely so this glob, README.md's claim about
-# where the pointer lands, and the deployed buildspec's artifact path all stay true.
-standing-resources-*.md
diff --git a/internal/integration-tests/.python-version b/internal/integration-tests/.python-version
deleted file mode 100644
index e4fba21..0000000
--- a/internal/integration-tests/.python-version
+++ /dev/null
@@ -1 +0,0 @@
-3.12
diff --git a/internal/integration-tests/DECISIONS.md b/internal/integration-tests/DECISIONS.md
deleted file mode 100644
index e91c675..0000000
--- a/internal/integration-tests/DECISIONS.md
+++ /dev/null
@@ -1,952 +0,0 @@
-# IPA Integration Tests — Decisions and Measurements
-
-Findings that would otherwise live only in a git-ignored temp project, and be lost
-with it. Every figure here is **measured**, never predicted.
-
----
-
-## Target
-
-| | |
-|---|---|
-| **Account** | `450282238889` |
-| **Caller** | `arn:aws:sts::450282238889:assumed-role/Admin/vincilb-Isengard` |
-| **Credentials** | The **default chain** — no profile is named in source. See Phase 6, D6.2. (Runs through 2026-08-17 recorded below used a named `IPA_IT_PROFILE`, since retired.) |
-| **Region** | `us-east-1` |
-| **Account type** | Isengard evaluation sandbox (`code-evaluation`), not production |
-
-The region is pinned rather than inherited. This shell exports
-`AWS_REGION=us-west-2`, and `/ipa-deploy` Step 1.5's `describe-stacks` is a bare
-`aws` call carrying no `--region` — so an inherited region sends that gate to the
-wrong region and hard-stops on a stack that exists.
-
----
-
-## Phase 1 — Preflight (run 2026-08-17)
-
-`python -m preflight` — nine probes, cheapest-first, no short-circuit. Plus one
-blocking check (`cognito_token`) held out of the probe table because it deploys a
-user pool, and `conftest.py` must not deploy one while deciding whether to skip.
-
-| Probe | Outcome | Detail (verbatim where negative) |
-|---|---|---|
-| `aws_profile_resolvable` | **PASS** | profile `peterpyu+code-evaluation-Admin` resolvable in `us-east-1` |
-| `sts_caller_identity` | **PASS** | `account=450282238889 arn=arn:aws:sts::450282238889:assumed-role/Admin/vincilb-Isengard` |
-| `claude_agent_sdk_importable` | **FAIL** | `optional dependency not installed: No module named 'claude_agent_sdk'` |
-| `npm_present` | **PASS** | `npm 11.17.0` |
-| `uv_present` | **PASS** | `uv 0.12.3 (Homebrew 2026-08-07 aarch64-apple-darwin)` |
-| `docker_daemon` | **PASS** | `docker server 29.6.2` |
-| `bedrock_worker_model` | **FAIL** | `worker model: MODEL NOT FOUND/INVALID -- Access denied. This Model is marked by provider as Legacy and you have not been actively using the model in the last 30 days. Please upgrade to an active model on Amazon Bedrock` |
-| `bedrock_driver_model` | **PASS** | `driver model: invoke ok (us.anthropic.claude-sonnet-4-20250514-v1:0)` |
-| `iam_create_role` | **PASS** | two named roles created and deleted under `itprobe` with `ReadOnlyAccess` and `CAPABILITY_NAMED_IAM` |
-| `cognito_token` *(blocking check)* | **PASS** | `IdToken aud == UserPoolClientId (5836p9aro9uv2pt6ma57f6mla5); itprobe-dev-cognito deleted; domain prefix itprobe-dev-62417329 released` |
-
-**7/9 probes passed; both blocking unknowns are retired.**
-
-### Both blocking unknowns are retired
-
-The two unknowns that could have made this feature impossible both resolved
-favourably, and both cost cents rather than a billable run:
-
-1. **`iam:CreateRole` with `CAPABILITY_NAMED_IAM` works in this account.** A
- two-role stack mirroring `/ipa-security`'s generated `iam.yml` — explicit
- `RoleName`s, one role trusting the account root and one trusting
- `codebuild.amazonaws.com`, each attaching one managed-policy ARN — deployed and
- deleted cleanly. **Route A for the security-stack deadlock is viable.**
-2. **`AdvancedSecurityMode: ENFORCED` does not block programmatic
- `InitiateAuth`.** The project's own `infra/cfn/cognito/cognito.yml`, deployed
- unchanged, returned an `IdToken` to `USER_PASSWORD_AUTH` with no adaptive-auth
- challenge — and the token's `aud` claim equals the `UserPoolClientId`, so the
- API Gateway audience check will pass in Phase 4. This mattered because the
- natural fallback is unavailable: `ALLOW_ADMIN_USER_PASSWORD_AUTH` is absent
- from the pool client's `ExplicitAuthFlows`, so `AdminInitiateAuth` would have
- required a change to a customer-facing template.
-
-### The two negative outcomes
-
-**`claude_agent_sdk_importable` — expected, not a defect.** Phase 1 declares only
-`boto3`; the SDK arrives behind an optional extra in Phase 2. A negative outcome
-here reads as a missing optional dependency, which is its designed behaviour on a
-machine that has not installed the extra.
-
-**`bedrock_worker_model` — a real finding, and it corroborates HITL default Q3.A.**
-`anthropic.claude-3-haiku-20240307-v1:0` is **not invocable in this account**: the
-provider has marked it Legacy and access is denied for not having been used in the
-last 30 days. Consequences:
-
-- The queue assertion in Phase 4 must **not** require a job to reach `COMPLETED`.
- It stops at "no longer `PENDING`" and **a `FAILED` job passes** — which was
- chosen on reasoning before this run and is now backed by a measurement: the
- model the worker calls cannot be invoked here at all.
-- The plan's alternative Q3.C — fail on `FAILED` only when the Bedrock probe
- passed — would have made the suite skip that assertion entirely in this account,
- which is a weaker signal than reporting the terminal status.
-- Whoever wants a `COMPLETED` job must either request access to an active model or
- point `IPA_IT_WORKER_MODEL` at one. Recorded rather than fixed, because the
- worker model is `app-lib`'s choice and changing it is a separate change.
-
-### Decisions that would otherwise read as oversights
-
-**`ReadOnlyAccess`, not `PowerUserAccess`** (HITL default K1.A). `/ipa-deploy`
-Step 1.5 checks only that `{ns}-{env}-security` reports `CREATE_COMPLETE` or
-`UPDATE_COMPLETE`; it never assumes either role, and `APP_BUILDER_ROLE_ARN`
-appears zero times in `MAKEFILE_TEMPLATES.md`. So `ReadOnlyAccess` satisfies that
-gate identically with a strictly smaller IAM footprint, and it removes the tension
-with `.context/README.md`'s "no wildcard IAM resource ARNs" constraint that
-`PowerUserAccess` would introduce — every AWS-managed policy is wildcard-scoped.
-
-The cost, stated so it is not lost: the run no longer exercises the option a real
-builder most likely picks. If `/ipa-security` ever starts validating that the
-chosen policy actually grants deploy permissions, this answer breaks — nothing in
-its Step 3a suggests it does. One constant (`PROBE_MANAGED_POLICY_ARN`) reverses
-the decision.
-
-**The JWT is decoded without verifying its signature**, deliberately and narrowly.
-The token was minted seconds earlier by the probe itself against the pool it had
-just deployed, and the only claim read is compared to a value from that same
-deployment. Verifying would add a JWKS fetch and a crypto dependency to establish
-something already known. This is **not** a pattern for anything that trusts an
-inbound token, and the docstring on `_jwt_payload` says so.
-
-### What the probe run creates, and that it cleans up
-
-Two stacks under namespace `itprobe` in `us-east-1`, both deleted by the run:
-
-- `itprobe-dev-security` — two IAM roles (free)
-- `itprobe-dev-cognito` — one user pool with a hosted-UI domain (free at this scale)
-
-Teardown runs in a `finally` path, so a probe that fails midway still cleans up.
-Verified afterwards:
-
-```
-$ aws cloudformation list-stacks --region us-east-1 \
- --query "StackSummaries[?starts_with(StackName,'itprobe') && StackStatus != 'DELETE_COMPLETE']"
-(empty)
-$ aws iam list-roles --query "Roles[?starts_with(RoleName,'itprobe')].RoleName"
-(empty)
-```
-
-The globally-unique Cognito domain prefix `itprobe-dev-62417329` was confirmed
-released. A leak there would make the probe non-repeatable, with a second run
-failing on a name the first run took — an error that names a name rather than a
-cause.
-
-### The generated password
-
-Generated with `secrets`, built by character class rather than by rejection
-sampling (all four classes are required by `cognito.yml`'s password policy, and
-rejection sampling can fail intermittently), and **held in memory only**. It is
-written to no file — not this one, not a transcript, not a report. Phase 4 adds the
-redaction filter for the sinks that will exist then; Phase 1's obligation is simply
-not to write it anywhere.
-
-### Notes on the probe design
-
-- **The Docker probe reads `{{.ServerVersion}}`, not the exit code.** `docker
- info` exits 0 with a partial payload when the daemon is down, so an exit-code
- check reports a working daemon that is not there — and the failure resurfaces
- twenty minutes later inside `build-rest-lambda`. Empty output is treated as a
- stopped daemon, which is the safe direction: a format change yields a false
- negative, never a false positive that costs a container build.
-- **The runner does not short-circuit.** Stopping at the first negative outcome
- would have hidden `bedrock_worker_model` behind `claude_agent_sdk_importable`,
- turning one round of environment repair into two — and nothing is saved, because
- the expensive probes are last.
-- **`import preflight` runs no probe and creates no AWS resource.** Verified.
-
----
-
-## Phase 2 — Offline harness (run 2026-08-17)
-
-`make test-offline`: **86 tests, all passing, 4.5 s, with no AWS credentials
-required.** ASH/bandit: **zero findings at MEDIUM or above** for the first-party
-modules.
-
-### Two premises in the plan were wrong, and both were found here rather than in a paid run
-
-**1. `scripts/test.mk` is TRACKED, so the temp project is not free of generated
-Makefiles.** The plan asserted that "`scripts/*.mk` is git-ignored
-(`scripts/.gitignore:2`) — so the copy starts with neither". `scripts/.gitignore`
-does list `*.mk`, but **git's ignore rules do not apply to a file that is already
-tracked**, and `git ls-files scripts/` returns `scripts/test.mk`. A blanket
-`scripts/*.mk` assertion therefore fails on every healthy build of this repository.
-
-Resolved by narrowing the pristine check to the Makefiles that actually **gate a
-skill branch** (`workspace.GATING_MAKEFILES`):
-
-- `.env` is what sends `/ipa-init` into its re-initialization branch, whose
- plain-text *"Which values would you like to change?"* no hook can intercept.
-- `scripts/deploy.mk` is what `/ipa-compose` Step 0.3 checks to select its
- idempotent-refresh mode (`ipa-compose/SKILL.md:57`).
-
-`test.mk` gates neither, so its presence changes no skill's path. The narrowing is
-correct rather than merely convenient, and two offline tests pin it: one asserts a
-tracked `test.mk` is tolerated, one asserts `deploy.mk` is refused.
-
-**2. `deploy:` and `teardown:` prerequisites never share a name**, so the
-"exact reverse" check cannot compare them raw. `MAKEFILE_TEMPLATES.md:54` generates
-`deploy: deploy-{suffix1} … deploy-{suffixN}` and `:162` generates
-`teardown: teardown-{suffixN} … teardown-{suffix1}`. A raw comparison reports
-**every healthy artifact as misordered** — a false positive that would have been
-recorded as a skill defect in Phase 3 and "fixed" in a skill that was correct.
-
-Resolved by comparing tier suffixes (`artifacts._suffixes`). An offline test asserts
-both that the suffix comparison passes on a healthy fixture and that the raw
-comparison would *not* — so the fix cannot silently regress.
-
-### Notes on the implementation
-
-- **The version purge is the normal path, not a fallback.** `aws s3 rm --recursive`
- runs first because it is fast, then a paginated `list-object-versions` /
- `delete-objects` sweep drains both `Versions` and `DeleteMarkers`
- unconditionally. `empty_bucket` treats the **purge** as authoritative, because
- `s3 rm` succeeding tells you nothing about whether `DeleteBucket` will: on a
- versioned bucket it leaves the bucket un-deletable while making it look empty. A
- test asserts that a **failed** listing does not report `emptied`.
-- **Nothing ties with `LOGS_RANK`.** `logs` is deliberately absent from
- `TIER_ORDER`; appending it is what creates a tie with an unknown tier, and under
- a stable sort a tie keeps input order. Tests assert no two distinct tiers share
- the rank, and that `logs` sorts last for **every permutation** of an input
- listing.
-- **An empty namespace is refused at the deleting boundary**, not interpreted as
- "every run". `select_namespaces` passes a listing through when given nothing —
- which is right for `list` — so the refusal lives in `cmd_teardown` and in the
- `teardown-namespace` target, and both are tested to delete nothing.
-- **`make_namespace(None)` generates; `make_namespace("")` raises.** An empty
- string is invalid, not absent — otherwise a caller that meant to inject a
- namespace would silently get a different one than it recorded. Callers reading an
- environment variable pass `os.environ.get(...) or None`.
-- **The VERBOSE `filter-out` form is verified, not assumed.** `make -n VERBOSE=0
- test-offline` emits `-q`; `VERBOSE=1` and unset both emit `-vv -s -rs
- --log-cli-level=INFO`. The `$(if $(VERBOSE),…)` form would have turned `VERBOSE=0`
- **on**, because Make's `$(if)` branches on non-emptiness rather than truth.
-- **`B404`, `B603` and `B607` are suppressed inline in source *and* by path in
- `.ash/ash.yaml`.** Measured with bandit against the first-party modules, only
- `B101` remains and MEDIUM+ is zero — the inline `# nosec` markers already cover
- the subprocess rules. The path suppressions are added anyway because ASH runs its
- own bandit configuration, which need not honour inline markers. Every subprocess
- call uses an argv list with `shell=False`, which is what makes the `B603`
- justification true rather than merely written.
-- **`output.py` was cut** (HITL default K2.A). The transcript and the
- standing-resource report already survive a lost terminal, and adopting it would
- also adopt a cross-suite coupling the reference documented as a cost.
-
-## Phase 3 — Compose-only drive (run 2026-08-17)
-
-> **Phase 3 is RED. Phase 4's billable run is BLOCKED** (HITL default S1.A: Phase 4
-> is gated on Phase 3 being *green*, not on Phase 3 having run once). Three findings
-> below, two of which are defects in shipped skills and must be fixed in their own
-> changes with bumping commit types.
-
-### Measured, not predicted
-
-| | |
-|---|---|
-| **Cost** | **$0.9417** |
-| **Turns** | **30** (ceiling 300) |
-| **Wall clock** | **377.8 s** (6 min 18 s) |
-| Stopped by a ceiling? | **No** — the agent ended on its own |
-| Namespace | `itf7676bc5d5` |
-| Temp project | `201edc74a788-dirty`, 690 files |
-| Gates seen | `compose:overwrite`, `security:deploy` |
-| Result | 5 passed, 10 failed, 3 errors |
-
-Well under the $5 ceiling (Q2.A), so the ceiling is not the constraint. A first
-harness-bug iteration cost a further ~$0 (aborted after 10 s on a `TypeError`).
-
-### What passed — the answer path works, through one channel only
-
-- **`APP_NAMESPACE` == `itf7676bc5d5`.** The single discriminator. The pinned
- namespace reached `.env` exactly.
-- `AWS_PROFILE` is the real profile name, **not** `Skip` — so defect 6 did not fire,
- as predicted.
-- `AWS_REGION=us-east-1`, `APP_ENV=dev`, `APP_IAC=cloudformation` all pinned.
-- No unexpected stack was deployed; the stop-after-compose instruction held.
-
-### F1 — `AskUserQuestion` was NEVER invoked, so the resolver never fired
-
-**The entire hook/resolver mechanism was bypassed.** Zero `ask_user_question` events
-in a 1,353-event transcript. The agent read `/ipa-init`'s prose, saw the pinned values
-in the appended system prompt, and wrote `.env` itself — verbatim:
-
-> *"Since this is an automated integration test, I'll configure the project with the
-> required values"*
-
-This is a **finding about the harness's model, not a skill defect**, and it inverts a
-plan premise. The plan treated the `PreToolUse` hook as the answer channel and the
-system-prompt namespace as a belt-and-braces second channel; in fact the system prompt
-was the *only* channel that operated, and the hook was never reached.
-
-Consequences:
-
-- `test_the_answer_channel_is_recorded` fails, correctly — it asserts a batch was
- answered, and none was.
-- **Answers 6–9 of the nine-entry table never applied at all**, because those
- questions are asked by `/ipa-compose` Step 1.5 and `/ipa-security`, which the agent
- also answered itself. See F2 for what it chose instead.
-- The measured reference behaviour (`iac_compose/FINDINGS.md` F2 — that `PreToolUse`
- answers and `can_use_tool` does not) is **neither confirmed nor refuted here**,
- because neither path was exercised. Both registrations stay: the `can_use_tool`
- tripwire is now *more* justified, not less, since the channel that fires is
- evidently not fixed.
-
-The fix belongs in the harness, not in a skill: either the system prompt must stop
-pre-supplying values so the questions are actually asked, or the harness must accept
-that the prompt is the channel and assert on `.env` rather than on channel use. That
-is a Phase 3 re-run, not a skill change.
-
-### F2 — the agent selected Route C, not Route A, so `ReadOnlyAccess` never applied
-
-Because no question was asked, nothing answered `/ipa-security` with `"Managed
-policy"` → `ReadOnlyAccess` → `"Yes, deploy"`. The agent took the **Recommended**
-option instead — *Innovation Builder Stack* (`ipa-security/SKILL.md:109`) — and
-deployed, verbatim:
-
-```
-Path Innovation Builder Stack
-Builder Role itf7676bc5d5-dev-builder
-CodeBuild Role itf7676bc5d5-dev-codebuild
-SageMaker Role itf7676bc5d5-dev-sagemaker
-EC2 Builder Role itf7676bc5d5-dev-ec2-builder
-Security Stack itf7676bc5d5-dev-security (IAM roles + permissions boundary + policies)
-```
-
-**Four roles, a permissions boundary and a 47-service policy** — where Route A with
-HITL default K1.A intended two roles with `ReadOnlyAccess`. So the smallest-footprint
-decision recorded in Phase 1 was silently not the one exercised, and the constraint
-tension that K1.A was chosen to remove was reintroduced by the agent's own default.
-
-Still free, still real, and it **was deleted** — see teardown below.
-
-### F3 — `/ipa-compose` generated ZERO of its eight artifacts
-
-The most consequential finding. After delegating to `/ipa-security` and reporting
-success, the run **ended without returning to `/ipa-compose`'s artifact generation**.
-`scripts/` afterwards held only what the repository copy already contained:
-
-```
-.gitignore INSTALL-RUNBOOK.md lint/ test.mk
-```
-
-None of `prepare.mk`, `deploy.mk`, `build.mk`, `post-deploy.mk`, `env.mk`,
-`release.mk`, `SECURITY-DISPOSITION.md`. `/ipa-compose` claims all eight
-(`ipa-compose/SKILL.md:266-274`).
-
-This answers research gap 9 — *"whether `/ipa-compose` generates all eight artifacts
-on a real run is unverified"* — with **no**, on this run.
-
-The proximate cause is a control-flow gap: `/ipa-compose` Step 1.5 delegates to
-`/ipa-security` and instructs *"On success … Proceed to Step 2"*
-(`ipa-compose/SKILL.md:130`), but nothing in the delegated skill returns control, and
-`/ipa-security`'s own completion report reads as terminal. The agent treated the
-security report as the end of the task. **This is a defect in a public interface**
-(`.claude/skills/ipa-compose/SKILL.md`) and must be fixed in its own change with a
-bumping commit type — not here.
-
-Because composition was never reached, the assertions that exist to catch defects 1–5
-could not run at all. That is the **cheap direction** and the recorded expectation:
-defects 1, 2 and 3 are detected by *reaching composition at all*. `test.mk` being the
-only `*.mk` present is also why Phase 2's tracked-`test.mk` finding mattered — a
-blanket `scripts/*.mk` pristine check would have masked this as a copy problem.
-
-### A false-positive in the gate scanner, worth recording
-
-`compose:overwrite` was recorded as a gate hit, but the matching text was
-`/ipa-compose`'s **own SKILL.md prose**, echoed into assistant output when the skill
-was loaded — not a question the agent asked. The scanner matches markers against all
-assistant text, including echoed skill content. Harmless here (the record is
-verbatim, so the false positive is visible on inspection) but it means a gate count is
-not a gate-crossing count.
-
-### Teardown worked, end to end
-
-The harness's own path removed everything, first try:
-
-```
-$ make teardown-namespace NAMESPACE=itf7676bc5d5
-Tearing down itf7676bc5d5 (1 stack(s))
- ok itf7676bc5d5-dev-security
- removed standing-resources pointer for itf7676bc5d5
-```
-
-Verified afterwards: no stack under any `it*` namespace, and no `itf7676*` IAM role.
-The standing-resource report fired through all three channels during the run
-(file, print, `UserWarning`), and the pointer was removed **only** after confirmed
-deletion — the reference's F5 behaviour, working as specified.
-
-### The preservation rule proved itself
-
-The temp project was **preserved** at session teardown because the security stack was
-standing — not because tests failed, and not deleted because tests failed. That is the
-rule working in the direction that matters: `.env` and the security stack's identifiers
-were still the only record of what had been created.
-
-## Phase 4 — Full lifecycle and live assertions (built; run BLOCKED)
-
-**Every module and assertion is written and collects. The billable run has not been
-executed, because Phase 3 is red** and HITL default S1.A gates Phase 4 on Phase 3
-being *green*, not on Phase 3 having run once. Running it now would spend a full
-deployment to rediscover F3 — the exact trade the gate exists to prevent.
-
-Built and verified as far as it can be without deploying:
-
-- `cognito_auth.py` — throwaway user, permanent password by character class,
- `USER_PASSWORD_AUTH`, returns the **ID token** and asserts `aud == client_id`. Its
- mechanism is already proven: Phase 1's preflight ran exactly this path against a real
- pool and got a token.
-- `test_deploy.py` — 20 assertions across seven-stack health, the served SPA and its
- `config.js`, the exactly-401 anonymous check, both authenticated checks, the direct
- `lambda:Invoke` container probe, the queue round trip, the DLQ, the queue outputs and
- the event-source mapping.
-- `test_teardown.py` — verify-live-then-reap, both removal directions, and the pointer
- check, behind both opt-ins.
-- **The redaction filter is complete and pinned by offline tests** (11 of them):
- a registered secret is replaced in the transcript, nested payloads included; an
- *un*registered value is **not** redacted, so over-redaction cannot silently destroy
- the verbatim gate text the transcript exists to preserve; the generated password
- satisfies all four required character classes across 200 generations; and every gate
- marker is asserted lowercase, since the scanner lowercases the haystack and an
- upper-case marker would never match.
-
-### The one write the assertions make — recorded as a narrowed exception
-
-**The assertions mutate nothing except a single throwaway user inside the run's own
-Cognito user pool.** The pool's own deletion removes it, so there is no separate cleanup
-obligation. Recorded explicitly because "the assertions are read-only" is the kind of
-claim that is either exactly true or quietly false, and the difference matters to
-whoever audits the account afterwards.
-
-### Unrecorded because unmeasured
-
-Cost, wall clock and turns for `make test` are **absent, not estimated**. The reference
-harness's $3.38 / 24 minutes for five stacks is from a different codebase and is quoted
-in `README.md` as scale only, never as a prediction for this one.
-
-## Phase 5 — CI seams (built and verified; nothing wired)
-
-Both seams work, and neither is invoked by anything.
-
-**`IPA_IT_STRICT_PREFLIGHT` measured both ways** against an unresolvable profile:
-
-| Mode | Result |
-|---|---|
-| unset | `45 skipped in 0.05s` |
-| `=1` | `45 errors in 0.21s`, nothing created |
-
-Fails fast for free because `preflight.py`'s probes are ordered cheapest-first, so an
-unresolvable profile is probe one. This is the seam that matters most: a skip is right on
-a laptop and catastrophic in a pipeline, where a build that skips every billable test
-**exits 0 having tested nothing** — a green result proving only that the runner started.
-
-**`IPA_IT_NAMESPACE` closed a live gap, not just added a seam.** Measured:
-
-```
-IPA_IT_NAMESPACE=itvalid99 -> resolver namespace: itvalid99
-IPA_IT_NAMESPACE=mycognito -> ValueError: namespace 'mycognito' contains 'cognito',
- which Cognito forbids in a hosted-UI domain prefix
-```
-
-The injected value passes **through** `make_namespace` rather than around it. Written the
-obvious way — `injected or make_namespace()` — an injected value would never be
-validated at all, because every assertion lives inside the generator, and a malformed one
-would fail many minutes later as a domain-prefix rejection naming nothing relevant.
-
-A related sharp edge, fixed while implementing: `make_namespace("")` must **raise**, not
-generate. `None` means "generate"; an empty string is invalid. Otherwise a caller that
-meant to inject would silently get a different namespace than it recorded, so callers
-reading an environment variable pass `os.environ.get(...) or None`.
-
-**Both teardown opt-ins are inline on recipe lines only** — verified by grep: lines 83,
-102 and 117 of the `Makefile`, all recipe lines. No `export`, no file-level variable. An
-ambient variable applies to every phase of every build, which is the "persists invisibly"
-case the two-part opt-in exists to prevent, and declaring it ambiently would defeat the
-mechanism *silently*, because the invocation that needs it still works.
-
-**Nothing is wired.** `grep -rn "test-ci\|integration-tests" .gitlab-ci.yml
-.github/workflows/` returns nothing.
-
-The single-source namespace test scans the harness's own modules for a restated pattern
-or forbidden-substring tuple. It is named for what it covers rather than for what it
-implies — it cannot detect a copy in code it does not inspect.
-
----
-
-## Where this leaves the feature
-
-| Phase | State |
-|---|---|
-| 1 Preflight | **green** — both blocking unknowns retired |
-| 2 Offline harness | **green** — 97 tests, ~5 s, $0, zero MEDIUM+ security findings |
-| 3 Compose drive | **RED** — F1, F2, F3 above |
-| 4 Deploy assertions | **built, run blocked** on Phase 3 |
-| 5 CI seams | **green**, wired to nothing |
-
-**Three follow-up changes, none of which belong in this feature** (Section 4 of the plan:
-no skill or template defect is fixed here, because `.claude/skills/**` is public
-interface and each fix needs its own bumping commit type). **Items 1 and 2 were done in
-Phase 7 below**, after F3 recurred on the deploy scenario; item 3 is still open:
-
-1. ~~**`fix(ipa):` `/ipa-compose` must resume after delegating to `/ipa-security`.**~~
- F3 — zero of eight artifacts. `ipa-compose/SKILL.md:130` says *"On success … Proceed
- to Step 2"* but nothing returns control and `/ipa-security`'s completion report reads
- as terminal. **Done in Phase 7 (F8)** — every delegation boundary now states that the
- turn must not end, and the drive nudges when the lifecycle is unfinished.
-2. ~~**Harness change: decide what the answer channel actually is.**~~ F1 —
- `AskUserQuestion` is never invoked when the system prompt pre-supplies the values, so
- the resolver never fires and answers 6–9 never apply. Either stop pre-supplying so the
- questions get asked, or accept the prompt as the channel and drop the channel
- assertion. **Both options rested on a wrong diagnosis — see F9.** The call *was*
- invoked and was **rejected** by input validation, because the skill asked five
- questions in one call with a one-option namespace question. Dropping the channel
- assertion would have deleted the one check that notices. Fixed in the skill in
- Phase 7; the assertion stays and is now in both scenarios.
-3. **Consider whether `/ipa-security`'s recommended default should be Route C.** F2 — an
- unattended agent picking the recommendation deploys a permissions boundary and a
- 47-service policy where two roles would satisfy every gate. Not a defect; a question
- about the default. **Still open**, and the 2026-08-18 run took Route C again — though
- for the reason in F9, not by preference.
-
-## Phase 6 — Scenario restructure and the default credential chain (2026-08-18)
-
-Split the flat harness into a shared `harness/` package plus one folder per scenario, and
-moved every AWS call onto botocore's default credential chain. **No billable run was
-performed in this phase**, and no pipeline run was started.
-
-### D6.1 — A shared package, not per-folder copies plus a loader
-
-The comparable LaunchBridge harness solves the same problem differently:
-`tests/integration/iac_deploy/_compose_modules.py:1-31` exists only because both its
-scenario folders define `workspace.py` and `answers.py`, so a bare `import workspace`
-resolves by `sys.path` ordering — that is, by pytest collection order, **silently**. Its
-own docstring names extracting a shared package as the better fix, declined there only
-because it would refactor a folder that already passed.
-
-IPA was being restructured anyway, so it took the better fix and never needs the
-workaround. Consequences recorded because they are what a reader will otherwise
-rediscover:
-
-- Scenarios import `harness.*`. No `sys.path` insertion, no explicit-path loader.
-- Shared fixtures load as a **pytest plugin** (`-p harness.fixtures`) rather than being
- copied into each scenario's `conftest.py`. Copying is fewer concepts and is what
- LaunchBridge did; it was rejected because the shared fixtures contain the preservation
- rule — the logic deciding whether to delete a temp project holding `.env` and
- `scripts/*.mk`, the only documented means of teardown. Two copies of a rule that decides
- whether to destroy the means of teardown will drift, and the drift nobody notices is the
- one that deletes more. A plugin is also the only home from which pytest collects the
- `pytest_runtest_makereport` hook.
-- `--import-mode=importlib` is set in every scenario's `pytest.ini`. It prevents nothing
- today — `test_compose.py` and `test_deploy.py` have distinct names — and is set now
- because the failure it prevents is **silent shadowing** rather than an error.
-
-### D6.2 — Credentials from the default chain; the hardcoded profile deleted
-
-`preflight.py` previously built every session as
-`boto3.Session(profile_name=PROFILE, region_name=REGION)` where `PROFILE` defaulted to one
-maintainer's SSO profile — which made the suite report a false negative for every other
-operator and for CI, since a CodeBuild container has no named profile at all.
-
-- `harness/aws.py` is now the single definition. `session()` pins `region_name` and passes
- **no** `profile_name`.
-- The `profile` parameter is gone from `standing.live_stacks`, `sweep.list_it_stacks`,
- `reaper.{purge_versions,empty_bucket,empty_ecr,reap}` and `cognito_auth.mint_token`,
- along with every `--profile` argv element. `sweep._resolve_profile` and
- `preflight.PROFILE` are deleted. `IPA_IT_PROFILE` is retired.
-- `cli_region_args()` was kept despite wrapping two list elements, as the grep-able marker
- that an argv site was **reviewed** during the removal. An inline `["--region", REGION]`
- is indistinguishable from a site that was missed.
-- The region stays pinned, and the reason is unchanged: this shell exports
- `AWS_REGION=us-west-2`, and an inherited region would probe an account that is clean
- only because nothing was ever there — a false pass indistinguishable from a real one.
- `preflight._session`'s docstring argued *against* ambient credentials on those grounds;
- it was rewritten rather than left, because a docstring contradicting its own code is
- worse than none, being believed.
-- The credential probe changed subject: `probe_aws_profile_resolvable` →
- `probe_credentials_resolvable`, whose detail reports
- `session().get_credentials().method`. Reporting only *that* credentials resolved would
- hide the failure worth catching — a CI build answering from a stray long-lived
- environment key instead of its task role has resolved credentials successfully and is
- still misconfigured.
-- **`AWS_PROFILE` keeps a value; the harness stops authenticating with it.** These are
- separable, and conflating them is what made the problem look unsolvable.
- `harness.aws.profile_for_env()` is the only reader and supplies just the value written
- into the driven project's `.env`. It **raises** when unset rather than falling back to
- `"default"`: that fallback is accepted by `/ipa-init`, written into `.env`, and then
- fails much later inside a generated Makefile with an error naming a missing profile
- rather than the configuration step that should have supplied one.
-
-### D6.3 — The pointer's `profile:` line became `credentials:`
-
-`standing.render_pointer` rendered `- profile: {profile}` into the standing-resource
-pointer, and `write_report` / `report_everywhere` threaded that parameter. Once teardown
-takes no profile, a `profile:` line in a human-facing teardown aid is misleading — the
-value available to put there is a credential resolution method, not a profile name. The
-parameter and the rendered label are both now `credentials`. Not named in the plan;
-recorded here as a consequence of dropping the parameter from `live_stacks`.
-
-### D6.4 — Two path anchors had to move with their modules
-
-`workspace.REPO_ROOT` and `preflight.REPO_ROOT` were both
-`Path(__file__).resolve().parents[2]`. Moving those modules down one level into `harness/`
-makes that resolve to `internal/`, not the repository root. Both are now `parents[3]`.
-
-Neither fails loudly. `workspace` would have copied the wrong tree into the temp project,
-and `preflight` would have resolved the Cognito template to
-`internal/infra/cfn/cognito/cognito.yml` — a path that does not exist — so the blocking
-check would have failed on a missing file rather than on anything about the account.
-`standing.HERE` was re-anchored deliberately for the same reason and is covered in the
-plan; these two were not, and were found by auditing every `__file__` in the package.
-
-### F6 (finding) — the deploy tier was uncollectable, not merely unrun
-
-`test_deploy.py:59` requested a `deploy_run` fixture **defined nowhere in the tree**.
-Measured against the pre-change tree:
-
-```
-$ uv run --directory internal/integration-tests python -m pytest \
- -m "integration or teardown" --setup-plan | grep -c "fixture 'deploy_run' not found"
-8
-```
-
-The distinction carries information and is the point of recording it: **an assertion suite
-that was never collectable is not one that was never run — it is one nothing could have
-run.** Phase 4 of this document described the deploy tier as "built; run BLOCKED", which
-was true of the *drive* and not of the *suite*.
-
-It stayed invisible because the routine free target deselects those markers, so the only
-command anyone runs regularly never touched them. That is why `--setup-plan` — which walks
-the whole fixture graph without executing any fixture, for free — became an acceptance
-criterion rather than a nicety. After this phase the same command reports **0** errors and
-the scenario collects 27 tests.
-
-Two things followed from writing the fixture, both decisions:
-
-- **Session scope, not module.** At module scope the fixture drives twice, once per test
- module: double the spend, double the standing infrastructure, and the teardown would
- then remove the *second* deployment while the assertions verified the *first* —
- reporting a successful verify-then-remove while the verified deployment stays up and
- billing. That is the failure the verify-before-remove ordering exists to prevent,
- reintroduced through fixture scope instead of through invocation count.
-- **`FULL_MAX_BUDGET_USD` is a second variable.** `driver.py` hardcoded
- `kwargs.setdefault("max_budget_usd", 15.0)`, so `IPA_IT_MAX_BUDGET_USD` — documented as
- the spend ceiling — could not reach the only path able to spend $15. It is now
- `IPA_IT_FULL_MAX_BUDGET_USD`, kept **separate** because the ceilings differ threefold
- and each scenario sets its own inline: one shared variable set for compose would cap a
- deploy run at $5 and stop it partway through a deployment, which is indistinguishable
- from a wedge and leaves partial infrastructure standing. `max_turns` deliberately stays
- a literal — a turn ceiling is a runaway-loop backstop, and making it configurable
- invites raising it to get past a wedge.
-
-### F7 (finding) — `AWS_PROFILE` is contradicted across five shipped skills
-
-This is in **public interface** and it blocks a documented customer path, not merely this
-harness.
-
-| Source | Says |
-|---|---|
-| `ipa-init/SKILL.md:21,126` | **Optional** — "omit to use default credential chain"; Skip omits the line from `.env` |
-| `ipa-compose/VALIDATION.md:21` | **Required** — "STOP immediately… do NOT generate any artifacts" |
-| `ipa-prepare/SKILL.md:91` | **Required** — "Missing `AWS_PROFILE`. Run `/ipa-init`." |
-| `ipa-security/SKILL.md:73` | **Required** — "STOP" |
-| `ipa-destroy/SKILL.md:74` | **Required** |
-| `ipa-compose/MAKEFILE_TEMPLATES.md:787` | Generated Makefiles are built for it being **empty**: "The `$(if …)` conditional ensures CI/CD compatibility when `AWS_PROFILE` is empty" |
-
-So IPA's *generated artifacts* already support the default credential chain while IPA's
-*skills* refuse to run without a profile. **Consequence for a customer: someone following
-`/ipa-init`'s documented "omit to use default credential chain" path cannot then run
-`/ipa-compose` at all.**
-
-**Not fixed here.** This phase works around it with a credential-less shim profile in
-CodeBuild. The fix is four small edits making the key optional in the four validation
-tables, and it belongs in its own change with a `fix:` type, because `.claude/skills/**`
-is public interface.
-
-### Two properties are INFERRED, not measured
-
-Recorded as inferred rather than asserted, and confirmed only by the first CodeBuild run —
-which no phase of this work starts:
-
-1. A `boto3.Session` with no explicit profile resolves the **container role** while
- `AWS_PROFILE=ipa-it` is exported.
-2. The AWS CLI given `--profile ipa-it` for a **credential-less** profile falls through to
- container credentials.
-
-Both follow from botocore dropping only `env_provider` — the chain link that reads
-`AWS_ACCESS_KEY_ID` from the environment — when a profile is configured, while retaining
-`container_provider`, the link that reads the ECS task role. That is an inference from the
-documented resolution order, not an observation from inside CodeBuild. The install phase's
-credential check is positioned so that if the inference is wrong, the build fails there —
-for free — rather than mid-drive after spending.
-
-### Corrections to earlier phases of this document
-
-- **The free suite has 99 tests.** Measured: `99 passed in 4.49s`. Earlier sections of
- this document say 100 and 97; both were approximate. Nothing was dropped in the move.
-- **The five free test modules lost their `test_offline_` prefix**
- (`test_answers.py`, `test_artifacts.py`, `test_reaper.py`, `test_redaction.py`,
- `test_workspace.py`). The prefix distinguished free from billable when both sat in one
- directory; `harness/tests/` now does that. Earlier cites in this document to
- `test_offline_*.py` refer to these files.
-- **Every module path in Phases 1–5 above moved into `harness/`.** A cite to
- `preflight.py:161` means `harness/preflight.py`.
-- **The documented pytest invocation was wrong.** Recipes must use
- `uv run python -m pytest`, not `uv run pytest`: only `-m` places the working directory
- on `sys.path`, which is the mechanism that makes `harness` importable without a build
- backend. With the bare console script every module fails with
- `ModuleNotFoundError: No module named 'harness'`. Measured, not assumed.
-- **The "Where this leaves the feature" table above predates this phase.** The deploy
- tier's "built, run blocked" now reads "collects and sets up; drive blocked on F3", and
- the flat tier names map onto the two scenario folders.
-- **Phase 1's probe table still names a maintainer's profile, and that is deliberate.**
- Its `aws_profile_resolvable` row is a **verbatim** record of a run on 2026-08-17, under
- a column header that says verbatim. Rewriting it to match the current design would
- falsify a dated observation, so it stands as history. The `## Target` table at the top of
- this document *was* updated, because that one describes the current target rather than a
- past run. The probe itself no longer exists under that name — see D6.2.
-
----
-
-## Phase 7 — First deploy drive (run 2026-08-18)
-
-The deploy scenario ran for the first time. It **reproduced F3 exactly**, on the full
-lifecycle rather than the compose scope, and produced two further findings — one of which
-corrects F1's diagnosis.
-
-### Measured, not predicted
-
-| | |
-|---|---|
-| Namespace | `itab40e6228e` |
-| Account | `369530416671`, profile `vincilb+ip-dev-Admin` — **not** the `450282238889` in this document's `## Target` table |
-| Drive | `$1.5159828`, 49 turns, 363.0 s wall clock |
-| Result | `ResultMessage(subtype='success', stop_reason='end_turn')`, `is_error=False` |
-| Ceiling | **Not** reached — `stopped_by_ceiling=false` against a `$15` ceiling and 600 turns |
-| Stacks created | **one**: `itab40e6228e-dev-security`, `CREATE_COMPLETE` |
-| Artifacts generated | **none** of the eight |
-| pytest | `15 failed, 1 passed, 5 deselected, 6 errors in 373.32s` |
-| Transcript | 1,076 events, `transcript-itab40e6228e.jsonl` |
-
-The single pass was `test_expected_stack_exists_and_is_healthy[security]` — the one
-assertion about the one free stack a truncated run happens to create.
-
-The account mismatch is recorded rather than corrected: the run authenticated through
-`AWS_PROFILE`, and which account the harness should target is a separate decision. Every
-figure above is from that account.
-
-### F8 — the lifecycle ended at the compose→security handoff, again
-
-F3, on the deploy scenario, with the agent's own reasoning visible this time
-(transcript event 1074, verbatim):
-
-> *"Since this is running in mode=init (called from /ipa-compose), I should return
-> control to the compose process with a completion message."*
-
-It printed the completion message and ended its turn. **Printing a report is what it
-took "return control" to mean** — which is the whole defect: a skill is loaded into the
-calling turn, so there is nothing to return to, and the only continuation is the agent
-carrying on. `/ipa-security/SKILL.md:476` said *"return control to compose silently"*
-while Step 8.4 above it specified a report block that reads as terminal, and the agent
-did both.
-
-Sequence, from the transcript: `Skill ipa-init` (70) → `Skill ipa-compose` (334) → four
-stack skills read (472–532) → `Skill ipa-security mode=init path=C` (579) → stack
-deployed → end of turn. `/ipa-prepare` and `/ipa-deploy` never ran, and neither
-`prepare:proceed` nor `deploy:proceed` appears in `gates_seen`.
-
-**What made it expensive to read**: nothing checked that the lifecycle finished, so the
-first thing to notice was `describe_stacks` on a stack nothing had created — twenty-one
-times, in `botocore` tracebacks naming neither `/ipa-compose` nor the turn that ended.
-The compose scenario already had the three gates that would have named it
-(`test_compose.py:51,55,76`); the deploy scenario had none.
-
-### F9 — `/ipa-init`'s `AskUserQuestion` call was REJECTED, which corrects F1
-
-F1 recorded that `AskUserQuestion` was never invoked and attributed it to the appended
-system prompt pre-supplying the values. **That diagnosis was wrong, and the correction
-matters because it changes the fix.** The call *was* invoked (event 201) and was rejected
-before rendering (event 202, verbatim):
-
-```
-InputValidationError:
- questions[2].options — Too small: expected array to have >=2 items
- questions — Too big: expected array to have <=4 items
-```
-
-`AskUserQuestion` accepts at most **4** questions per call and requires at least **2**
-options per question; the built-in "Other" path does not count toward the minimum.
-`/ipa-init/SKILL.md:111` mandated a single call with **five** questions, and its
-`APP_NAMESPACE` question offered exactly one option, `"app" (Recommended)` — so the call
-broke both limits at once and **could never have succeeded on any run, attended or not**.
-The agent then wrote `.env` itself from the pinned values in the appended prompt, which
-is why F1 read as "the prompt was the only channel".
-
-Consequences, which are wider than F1 concluded:
-
-- The whole of `harness/answers.py` has never applied to any run. Answers 6–9 are
- untested not because the agent preferred its own defaults but because no batch ever
- reached the hook.
-- F2 (Route C instead of Route A) follows from this rather than from agent preference.
- The 2026-08-18 run took Route C again.
-- The recommended fix in F1 — *"accept that the prompt is the channel and drop the channel
- assertion"* — would have deleted the one assertion that notices, and left every real
- `/ipa-init` user eating a rejected tool call. **Do not do that.**
-
-### F10 — the delegation letters are crossed (found by inspection, not measured)
-
-`/ipa-compose` Step 1.5 offers A) Existing Role ARN, B) Managed Policy ARN,
-C) Innovation Builder Stack, and passed `path={A|B|C}` to `/ipa-security`, whose steps
-are 3a Managed, 3b Existing, 3c Innovation. **Two of the three letters mean different
-things on each side.** `path=A` invites Step 3a and prompts for a managed policy where
-the builder asked to supply their own role ARNs; only `C` happens to line up, which is
-the path both measured runs took, which is why no run has hit this.
-
-Recorded as inspection, not observation: no run has exercised `path=A` or `path=B`.
-
-### The gate scanner's false positive recurred
-
-`compose:overwrite` was recorded again, and again the matching text was
-`/ipa-compose`'s own SKILL.md prose echoed into assistant output when the skill loaded —
-not a question. As in Phase 3: the record is verbatim, so the false positive is visible
-on inspection, but a gate count is still not a gate-crossing count.
-
-### D7.1 — the completion gate fails the FIXTURE, not each assertion
-
-`harness/lifecycle.py` now decides what "finished" means — all eight artifacts present,
-all seven stacks live — and `deploy/conftest.py` asserts it in `deploy_run`. The predicate
-lives in the shared package rather than the scenario because a scenario conftest is
-unreachable from the offline suite, and this is the function that decides whether a
-billable run gets re-queried. One inaccurate `subtype='success'`
-therefore produces one message naming what is missing, instead of twenty-one failures
-naming stacks nothing created.
-
-Failing in the fixture is safe **because `test_teardown.py` depends on `namespace` and
-`temp_project`, never on `deploy_run`** — so the teardown path and the standing-resource
-report both still run. That was checked before choosing fixture-level over a first test.
-
-The predicate reads disk and AWS, never the agent's account of what it did, and
-`harness/standing.py` now holds `EXPECTED_TIERS` as the single definition so the gate and
-the seven-stack assertions cannot disagree.
-
-### D7.2 — the drive nudges, bounded at two, and counts every nudge
-
-`driver.drive` takes an `is_complete` predicate and re-queries the same client when the
-turn ends with the lifecycle unfinished. A ceiling stop is never nudged.
-
-**The nudge is a backstop, not the fix** — the fix is in the skills. Every nudge is
-counted on `RunResult.nudges`, recorded in the transcript, and printed, so a run that only
-completed because it was pushed reports that instead of looking clean. `IPA_IT_MAX_NUDGES=0`
-measures the skills as authored. The cap is 2 deliberately: raising it converts "a skill
-stops early" into "the harness pays repeatedly to push it through", hiding the defect the
-counter exists to report.
-
-### D7.3 — the standing-resource report stopped calling one IAM stack BILLABLE
-
-The 2026-08-18 warning read *"1 BILLABLE stack(s) left standing"* about a stack holding
-four IAM roles, a permissions boundary and a policy — nothing charged for. The report now
-classifies by tier (`security` free; everything else, including anything unrecognised,
-billable) and says how many of the standing stacks are billable. A report that overstates
-teaches a maintainer to discount it, which is the wrong lesson on the day the standing
-stack is a CloudFront distribution and two Lambdas.
-
-### What was changed, and what remains unmeasured
-
-Fixed in this change: `/ipa-init` asks 4+1 with two namespace options (F9);
-`/ipa-security` and `/ipa-compose` state the continuation obligation at every delegation
-boundary, and `/ipa-init` does too for compose's auto-init gate (F8); `path=` is passed by
-name with the letter mapping documented (F10); plus D7.1–D7.3 and fifteen offline
-tests (`harness/tests/test_nudge.py`), taking the free suite from 99 to 114.
-
-**Neither scenario has been re-run.** The skills' behaviour under the new instructions is
-unobserved, this scenario's cost is still unmeasured, and the twenty deployment assertions
-are still unexercised. `nudges=` on the next run is the measurement that matters: zero
-means the skills now carry themselves through, and anything above zero means one still
-stops early and the harness pushed it.
-
-## Phase 8 — The fixes hold; one test was wrong (run 2026-08-19)
-
-Phase 7 closed by naming `nudges=` as the measurement that mattered. **It came back zero.**
-The skills carried the lifecycle from `/ipa-init` to seven deployed stacks without the
-harness pushing them once, and `AskUserQuestion` fired for the first time in the project's
-history — five calls, every one answered by the resolver.
-
-### Measured, not predicted
-
-| | |
-|---|---|
-| Namespace | `it9047b2a99f` |
-| Source | commit `e63b197`, clean tree, 715 files |
-| Driver model | `us.anthropic.claude-sonnet-4-20250514-v1:0` |
-| Drive | `$4.3093221`, 98 turns, 1919.9 s (32.0 min) wall clock |
-| **Nudges** | **0** — `incomplete_reason=""`, so the completion gate passed on its first check |
-| Ceiling | **Not** reached — `stopped_by_ceiling=false` against `$15` and 600 turns |
-| Stacks created | **all seven**, 6 of them billable |
-| Artifacts generated | **all eight** (the gate would not have passed otherwise) |
-| `ask_user_question` | **5** events, all `via=pre_tool_use_hook`, all with `answers` populated |
-| Gates | 5: `compose:overwrite`, `security:deploy`, `prepare:proceed`, `deploy:proceed`, `deploy:build_failed` |
-| pytest | `1 failed, 22 passed, 5 deselected, 1 warning in 1946.63s` |
-| Transcript | 3,381 events, `transcript-it9047b2a99f.jsonl` |
-
-Cost is now measured rather than bounded: **`$4.31`**, against the `$15` ceiling that was
-chosen without data. The drive is 2.8× Phase 7's spend for 2.0× the turns — Phase 7 stopped
-at a tenth of the lifecycle, so its `$1.52` was never a useful estimate of anything.
-
-### What this settles about F8, F9 and F10
-
-All three were fixed by instruction changes alone, and each now has a measurement:
-
-* **F8** (the turn ended at the compose→security handoff, twice) — `nudges=0` with all
- eight artifacts and all seven stacks. The handoff was crossed unaided. This is the one
- that could only ever be proven by a live run, because the failure was the agent's
- reading of a sentence.
-* **F9** (`/ipa-init`'s `AskUserQuestion` call was rejected) — five accepted calls,
- split 4-then-1 exactly as re-authored. The second call carries the single IaC question.
- Every prior run in this document recorded **zero** such events; `harness/answers.py`
- had never applied to anything until this run.
-* **F10** (crossed path letters) — the security question was answered
- `Managed Policy ARN`, then `ReadOnlyAccess`, and the stack deployed. The path the
- resolver named is the path that ran.
-
-`deploy:build_failed` and `deploy:proceed` are stamped in the **same second** (15:51:35)
-from the same skill document, which is the gate-scanner false positive already recorded in
-Phase 6: the scanner matches its markers against the loaded `SKILL.md` prose, not against
-output. Seven healthy stacks and 22 passing assertions confirm no build failed. The scanner
-is still worth narrowing, and it is still not lying about anything load-bearing.
-
-### F11 — the queue round-trip test posted a body the API never accepted
-
-The single failure, and it is **in the test, not in IPA**:
-
-```
-POST /api/v1/jobs -> 422: {"detail":[{"type":"missing","loc":["body","input_data"],
-"msg":"Field required","input":{"prompt":"integration test job"}}]}
-```
-
-`test_a_submitted_job_leaves_pending` posted `{"prompt": "integration test job"}`. The
-shipped contract is `JobCreate` — `job_type` (defaulted) and `input_data` (**required**) —
-and no part of `app-lib` has ever read a `prompt` field: the worker reads
-`input_data["ticket"]` (`sqs_handler.py:60`). FastAPI rejected the body at validation, so
-`submit_job` never ran. The same `{job_type, input_data:{ticket}}` shape is stated
-independently in three places — `job_dto.py:10`, `web-client/src/mocks/handlers.ts:84`, and
-`specs/011-passenger-jobs/contracts/jobs-api.md` — and the test agreed with none of them.
-
-The body was written from the *idea* of a job rather than from the DTO, and nothing caught
-it because this assertion had never once executed: Phase 7 died before any stack existed,
-and every earlier phase deselected the marker. **A test that has never run is not a test**,
-and its first execution is where its own defects surface, not the subject's.
-
-Fixed by posting the real contract with `ticket` `113803` (the spec's own example), and the
-assertion message now names `job_dto.py` so the next 422 is read as a contract drift rather
-than an outage. The docstring also now states the expected terminal status on a fresh
-namespace: `FAILED` with `Passenger not found: 113803`, because the passenger table is
-empty until someone loads it. That is not a regression — `process_job` catches every
-exception, records it on the job and returns 200, so the message never reaches the DLQ and
-the DLQ assertion stays green while this one still proves the worker consumed the queue.
-
-### What remains unmeasured
-
-The 422 means **the queue round trip itself is still unproven end to end**: the job was
-never created, so nothing was enqueued, and `test_a_submitted_job_leaves_pending` has yet
-to observe a single status transition. Everything either side of it — the DLQ being empty,
-the event source mapping being enabled, the five queue outputs resolving — passed, so the
-infrastructure is verified and the *traversal* is not. The next deploy run is the first
-that will exercise it, and it is the only assertion in this file whose subject has never
-executed once.
diff --git a/internal/integration-tests/Makefile b/internal/integration-tests/Makefile
deleted file mode 100644
index 8d92fa5..0000000
--- a/internal/integration-tests/Makefile
+++ /dev/null
@@ -1,127 +0,0 @@
-# IPA integration tests — maintainer-only harness.
-#
-# This is the UMBRELLA. It delegates scenario work to the scenario folders and owns only
-# what belongs to no single scenario.
-#
-# Bare `make` prints help and creates nothing.
-
-# VERBOSE trap, and it is worth stating because the obvious form is wrong:
-#
-# WRONG: $(if $(VERBOSE),-vv -s,)
-#
-# Make's $(if) branches on NON-EMPTINESS, not truth. `VERBOSE=0` is non-empty, so that
-# form turns verbosity ON when a user asked for it off. `filter-out` against a list of
-# disabling words is what actually tests the value.
-OFF := 0 no false off
-VERBOSE ?= 1
-PYTEST_FLAGS := $(if $(filter-out $(OFF),$(VERBOSE)),-vv -s -rs --log-cli-level=INFO,-q)
-
-# `python -m pytest`, NOT bare `pytest`. `-m` places the working directory on sys.path,
-# which is what makes `harness` importable with no build backend and no `pythonpath`
-# entry. The bare `pytest` console script does NOT, and every module then fails with
-# `ModuleNotFoundError: No module named 'harness'`. Measured, not assumed.
-PYTHON := uv run python
-PYTEST := uv run python -m pytest
-NAMESPACE ?=
-IPA_IT_REGION ?= us-east-1
-export IPA_IT_REGION
-
-# Which scenario `test-ci` runs. Defaults to the CHEAP one, and that default is a SPEND
-# DECISION rather than a convenience: `test-ci` is the name automation reaches for, so an
-# unparameterised invocation costs about $1 here instead of about $15.
-SCENARIO ?= compose
-
-.DEFAULT_GOAL := help
-
-.PHONY: help preflight test-offline test-compose test-deploy test-ci \
- list-stacks teardown-namespace clean
-
-help:
- @echo ""
- @echo "IPA integration tests — maintainer-only. Nothing here runs in CI by itself."
- @echo ""
- @echo " SCENARIOS (each folder owns its own Makefile, cost and README)"
- @echo " compose /ipa-init + /ipa-compose, stops before /ipa-prepare."
- @echo " MEASURED ~\$$0.94. Creates 1 free-but-real IAM stack."
- @echo " make test-compose (or: make -C compose test)"
- @echo " deploy FULL lifecycle. Deploys SEVEN stacks and LEAVES THEM"
- @echo " STANDING. Cost UNMEASURED."
- @echo " make test-deploy (or: make -C deploy test)"
- @echo ""
- @echo " FREE"
- @echo " make test-offline Shared-module regression suite, no credentials (~5s, \$$0)"
- @echo " make list-stacks List standing integration-test stacks (\$$0, read-only)"
- @echo ""
- @echo " COSTS MONEY"
- @echo " make preflight Nine probes + Cognito check. Deploys and DELETES"
- @echo " two stacks under namespace 'itprobe'. (cents)"
- @echo ""
- @echo " REMOVES BILLABLE RESOURCES"
- @echo " make teardown-namespace NAMESPACE="
- @echo " Remove ONE run's stacks. NAMESPACE is required;"
- @echo " empty is never treated as 'every run'."
- @echo ""
- @echo " CI SEAM"
- @echo " make test-ci Delegates to \$$(SCENARIO)/Makefile's test-ci."
- @echo " SCENARIO defaults to 'compose' (the cheap one)."
- @echo " make test-ci SCENARIO=deploy # ~\$$15"
- @echo ""
- @echo " Options"
- @echo " VERBOSE=0 Compact output (default is -vv -s)"
- @echo " IPA_IT_NAMESPACE= Pin the run namespace (validated at source)"
- @echo " IPA_IT_STRICT_PREFLIGHT=1 Unmet prerequisites FAIL instead of skipping"
- @echo ""
- @echo " IPA_IT_MAX_NUDGES=0 Do not push a drive that ends its turn early"
- @echo ""
- @echo " Both scenarios last FAILED at composition (DECISIONS.md F3/F8). The cause is"
- @echo " fixed in the skills; neither has been re-run, so neither is green yet."
- @echo ""
-
-preflight:
- $(PYTHON) -m harness.preflight
-
-test-offline:
- $(PYTEST) $(PYTEST_FLAGS) -c harness/tests/pytest.ini harness/tests/
-
-test-compose:
- $(MAKE) -C compose test
-
-test-deploy:
- $(MAKE) -C deploy test
-
-# Refuses an unknown scenario BY NAME. Without this guard, `make test-ci SCENARIO=compse`
-# reaches `make -C compse` and fails with "No such file or directory" — an error naming a
-# missing directory rather than a mistyped scenario, which reads as a broken repository.
-#
-# Tests for the MAKEFILE, not the directory: a directory that exists but holds no Makefile
-# is not a scenario either, and delegating into it produces a different confusing error
-# for the same class of mistake.
-test-ci:
- @if [ ! -f "$(SCENARIO)/Makefile" ]; then \
- echo "Unknown scenario '$(SCENARIO)'. Valid scenarios:" >&2; \
- ls -d */Makefile 2>/dev/null | sed 's|/Makefile||;s|^| |' >&2; \
- exit 2; \
- fi
- $(MAKE) -C "$(SCENARIO)" test-ci
-
-# list-stacks and teardown-namespace stay on the UMBRELLA, not in a scenario: a namespace
-# sweep finds any run's stacks by prefix and belongs to no single scenario. Putting them in
-# one would mean cleaning up after `deploy` required invoking `compose`.
-list-stacks:
- $(PYTHON) -m harness.sweep list
-
-# NAMESPACE is required. An empty namespace exits non-zero rather than being read as
-# "every run" — that is the unbounded failure mode, which would delete other runs'
-# infrastructure and possibly infrastructure that is not a test run at all.
-teardown-namespace:
- @if [ -z "$(NAMESPACE)" ]; then \
- echo "NAMESPACE is required. Refusing to treat an empty namespace as 'every run'." >&2; \
- echo "Usage: make teardown-namespace NAMESPACE=" >&2; \
- exit 2; \
- fi
- IPA_IT_TEARDOWN=1 $(PYTHON) -m harness.sweep teardown $(NAMESPACE)
-
-clean:
- $(MAKE) -C compose clean
- $(MAKE) -C deploy clean
- rm -rf .pytest_cache __pycache__ harness/__pycache__ harness/tests/__pycache__
diff --git a/internal/integration-tests/README.md b/internal/integration-tests/README.md
deleted file mode 100644
index 969c334..0000000
--- a/internal/integration-tests/README.md
+++ /dev/null
@@ -1,239 +0,0 @@
-# IPA integration tests
-
-A maintainer-only pytest harness that drives the real IPA lifecycle — `/ipa-init` →
-`/ipa-compose` → `/ipa-prepare` → `/ipa-deploy` — unattended through the Claude Agent
-SDK against a live AWS sandbox, then **asserts the deployed architecture works** rather
-than that the run finished.
-
-Nothing here runs in CI on a trigger. A CodePipeline exists
-(`internal/infra/codepipeline/`) but is inert (`PollForSourceChanges: false`) and is
-started by hand: wiring a trigger would commit the project to paying for a billable run.
-
-```bash
-make # help; creates nothing
-make test-offline # $0, ~5s, no AWS credentials needed
-```
-
-## Layout
-
-Shared code in one package, one folder per scenario:
-
-```text
-harness/ the shared package — imported as harness.*
- aws.py the ONE credential and region policy
- fixtures.py shared session fixtures, loaded as a pytest plugin
- lifecycle.py what "the lifecycle finished" means — disk and AWS, not the agent
- tests/ the free regression suite for the modules above
-compose/ scenario: /ipa-init + /ipa-compose, then stop
-deploy/ scenario: the full lifecycle
-Makefile the umbrella — delegates to scenarios
-```
-
-A scenario is the unit of invocation. That is what keeps the cheap tier cheap to reach: a
-composition defect costs about a dollar to find with `compose`, or about fifteen to
-rediscover with `deploy`, and the difference only survives if the cheap drive is a
-first-class thing you can run and cost on its own rather than a marker expression you
-have to remember to narrow.
-
-## Scenarios
-
-| Scenario | Drives | Cost | Leaves standing |
-|---|---|---|---|
-| `compose` | `/ipa-init`, `/ipa-compose` — stops before `/ipa-prepare` | **$0.94 / 6 min 18 s — measured** | 1 stack: `{ns}-dev-security` (2 IAM roles — free but real) |
-| `deploy` | the full lifecycle, all four skills | **$4.31 / 32 min — measured** | 7 stacks, on purpose (6 billable) |
-
-Both figures are **measured, not estimated** — `deploy`'s from the 2026-08-19 run that
-completed the lifecycle with zero nudges (`DECISIONS.md` Phase 8). Add the `$15` ceiling,
-not the `$4.31`, to any budget: the measurement is one run on one account, and the ceiling
-is what a run is actually allowed to spend.
-
-```bash
-make test-compose # or: make -C compose test
-make test-deploy # or: make -C deploy test
-make -C compose # that scenario's own help, cost and status
-```
-
-Each scenario folder has its own `README.md` stating what it drives, what it costs, what
-it leaves behind and how to remove it.
-
-## Current status
-
-| What | Command | Status |
-|---|---|---|
-| Free suite | `make test-offline` | **green** — 114 tests, ~5 s, $0, no credentials |
-| Preflight | `make preflight` | **green** where credentials resolve — 9 probes + 1 blocking check |
-| `compose` scenario | `make test-compose` | **not re-run** since its cause was fixed |
-| `deploy` scenario | `make test-deploy` | **green** — 22 passed, 1 fixed since (2026-08-19) |
-
-The cause that had truncated every prior drive is **fixed and measured**: `/ipa-compose`
-generated none of its eight artifacts because nothing brought the agent back from
-`/ipa-security` (`DECISIONS.md` F3, again as F8). The 2026-08-19 deploy run generated all
-eight, deployed all seven stacks and reported **`nudges=0`** — the skills crossed every
-delegation boundary unaided. `compose` is expected to pass for the same reason and has not
-been re-run to confirm it.
-
-That run's single failure was **in a test, not in IPA**: the queue round trip posted a body
-`JobCreate` never accepted, so it drew a 422 (F11, now fixed). It had never executed before
-— earlier runs died first — which is why its first execution is where it surfaced. The
-queue *traversal* is therefore still unproven; every other queue assertion passed.
-
-**Neither scenario has been re-run, so neither is green.** On the next run, read
-`nudges=` in the drive's printed line first: zero means the skills carried themselves
-through, and anything above zero is a skill that still stops early.
-
-The `deploy` scenario was, until recently, **uncollectable** rather than merely unrun: it
-requested a `deploy_run` fixture that nothing defined, so it failed at *setup* with eight
-errors before any probe ran. See `DECISIONS.md` Phase 6.
-
-## Credentials
-
-**Credentials come from the default chain.** No harness AWS call names a profile — not as
-a session argument, not as a CLI flag — and no profile name appears as a default in
-source. One code path therefore serves both places this runs:
-
-| Where | What answers |
-|---|---|
-| A workstation | an exported `AWS_PROFILE`, an SSO session, or environment keys |
-| CodeBuild | the ECS **container role**, via the container credential provider |
-
-`make preflight`'s first row reports **which** link answered (`env`, `sso`,
-`container-role`, `iam-role`), not merely that one did — because a CI build answering from
-a stray long-lived key has resolved credentials successfully and is still misconfigured.
-
-`AWS_PROFILE` is still read, for exactly one purpose: it supplies the value the agent
-writes into the driven project's `.env`. Four shipped skills stop when that key is absent,
-so a run needs a name there — but nothing in this harness needs one to make an AWS call.
-`harness.aws.profile_for_env()` is the only reader, and it **raises** when unset rather
-than falling back to `default`, because `AWS_PROFILE=default` in `.env` is accepted by
-`/ipa-init` and then fails much later inside a generated Makefile with an error naming a
-missing profile rather than the configuration step that should have supplied one.
-
-## How to remove what a run leaves standing
-
-```bash
-make list-stacks # what is standing, across all runs
-make teardown-namespace NAMESPACE= # remove ONE run
-```
-
-Both live on the umbrella rather than in a scenario: a namespace sweep finds any run's
-stacks by prefix and belongs to no single scenario.
-
-After a run, a `standing-resources-.md` file is written at the **harness
-root** — beside this README — naming the namespace, the stacks, and the exact removal
-command. It is readable with no AWS credentials configured, because its whole job is to
-survive a lost terminal. It is deleted only after a teardown confirms every deletion — a
-pointer that outlives a successful teardown teaches you to distrust the next one, which
-may be true.
-
-`make teardown-namespace` requires `NAMESPACE`. An empty namespace exits non-zero rather
-than being read as "every run".
-
-Teardown also does the three things IPA's own skills refuse to do: drain the versioned
-buckets (including every non-current version and delete marker — `aws s3 rm` alone leaves
-the bucket un-deletable while making it look empty), empty the ECR registry, and delete
-`{ns}-dev-security`.
-
-## Safety properties worth knowing before you run anything
-
-- **Teardown needs two independent opt-ins**: the `teardown` pytest marker *and*
- `IPA_IT_TEARDOWN=1`. One is satisfiable by accident — a pipeline variable applies to
- every phase of every build. Both are set **inline** on the recipe lines that need them
- and nowhere else.
-- **Teardown never widens.** It is scoped to one namespace; an unmatched namespace
- selects nothing, and an empty one is refused.
-- **`make test-ci` defaults to the cheap scenario.** `SCENARIO ?= compose`, because
- `test-ci` is the name automation reaches for, so its default is a spend decision. An
- unknown `SCENARIO` is refused by name, listing the valid ones.
-- **The temp project is preserved whenever stacks are standing** — never on the basis of
- tests passing, and preserved too when the standing check itself errors. It holds `.env`
- and `scripts/*.mk`, the only documented means of teardown, so deleting them while stacks
- stand would remove the means of removing what is still costing money.
-- **The one secret this harness generates** — a throwaway Cognito password — is held in
- memory only and redacted from all three sinks that could capture it (the JSONL
- transcript, `DECISIONS.md`, and the standing-resource report). A free test pins the
- filter.
-- **The assertions mutate nothing** except a single throwaway user inside the run's own
- Cognito user pool, which the pool's own deletion removes.
-- **Each scenario's spend ceiling is set per invocation**, never exported. The two
- ceilings differ threefold, so an ambient one would let the cheap scenario cap an
- expensive run and stop it mid-deployment.
-
-## Configuration
-
-| Variable | Default | Meaning |
-|---|---|---|
-| *(credentials)* | default chain | **No variable.** See [Credentials](#credentials) — an exported `AWS_PROFILE` or SSO session locally, the container role in CodeBuild |
-| `AWS_PROFILE` | *(required)* | Read **only** to supply the value written into the driven project's `.env`. Never used to build a session. Raises when unset |
-| `IPA_IT_REGION` | `us-east-1` | Pinned; an inherited region would probe an account that is clean only because nothing was ever there |
-| `IPA_IT_NAMESPACE` | generated | Pin the run namespace. Validated at source |
-| `IPA_IT_STRICT_PREFLIGHT` | unset | Unmet prerequisites **fail** instead of skipping |
-| `IPA_IT_TEARDOWN` | unset | Second teardown opt-in |
-| `IPA_IT_MAX_BUDGET_USD` | `5` | Spend ceiling for the **compose** scenario |
-| `IPA_IT_FULL_MAX_BUDGET_USD` | `15` | Spend ceiling for the **deploy** scenario. Separate on purpose: the two differ threefold, and one shared value set for compose would cap a deploy run at $5 and stop it mid-deployment — indistinguishable from a wedge, and leaving partial infrastructure standing |
-| `SCENARIO` | `compose` | Which scenario `make test-ci` runs |
-| `VERBOSE` | `1` | `VERBOSE=0` for compact output |
-
-Verbose output is on by default. Over the deploy scenario's measured **32 minutes**,
-per-test output is the only way to tell progress from a wedge — and a wedged half-hour
-billable run with nothing on screen is not recoverable by re-running it.
-
-## Adding a scenario
-
-A scenario is a folder with **five** files. Copy `compose/` and change them:
-
-| File | Holds |
-|---|---|
-| `Makefile` | `help` (default goal), `test`, `test-ci`, `clean` |
-| `pytest.ini` | this scenario's markers, plus the shared-fixtures plugin |
-| `conftest.py` | **only** the drive fixture specific to this scenario |
-| `README.md` | what it drives, what it costs, what it leaves standing, how to remove it |
-| `test_*.py` | the assertions |
-
-Three details are load-bearing rather than stylistic:
-
-1. **`addopts` must include `--import-mode=importlib`.** Under pytest's default `prepend`
- mode, two scenarios each holding a same-named test module collide and one **silently**
- shadows the other — collection succeeds and one module's assertions never run. This is
- the one whose absence does not produce an error.
-2. **`addopts` must include `-p harness.fixtures`.** That loads the shared fixtures as a
- plugin. It has to be a plugin rather than an import, because `harness.fixtures` also
- supplies the `pytest_runtest_makereport` hook and pytest collects hooks only from
- `conftest.py` files and plugins.
-3. **Every pytest recipe must `cd` to the harness root and use `python -m pytest`**, not
- bare `pytest`. `-m` places the working directory on `sys.path`, which is what makes
- `harness` importable with no build backend and no `pythonpath` entry. The bare console
- script does not, and every module then fails with `ModuleNotFoundError: No module named
- 'harness'`.
-
-Then add the scenario to the umbrella's `help` and a `test-` delegating target. The
-umbrella's `SCENARIO` guard picks it up automatically — it globs `*/Makefile`.
-
-Set the scenario's spend ceiling **inline** on its recipe lines. Never `export` it: an
-ambient ceiling applies to every recipe in the file and to anything else on the same
-runner.
-
-## Files
-
-### `harness/` — the shared package
-
-| File | Role |
-|---|---|
-| `aws.py` | **The one credential and region policy.** `session()` names no profile; `profile_for_env()` answers a different question and never builds a session |
-| `fixtures.py` | `credentials`, `resolver`, `namespace`, `transcript`, `temp_project`, the single `_unmet` prerequisite helper, and the preservation rule. Loaded as a plugin |
-| `preflight.py` | Nine probes + the Cognito blocking check. Each returns `(ok, detail)` and never raises |
-| `workspace.py` | Builds the temp project from the **working tree**, not from a commit |
-| `answers.py` | The nine pinned answers; `make_namespace` validates at the point of generation |
-| `driver.py` | The SDK session, both answer channels, the per-event transcript, gate markers, both spend ceilings |
-| `artifacts.py` | `scripts/*.mk` parsers, kept separate so composition assertions are testable offline |
-| `cognito_auth.py` | Mints a real ID token from the run's own pool |
-| `standing.py` | `live_stacks` (never raises) and the credential-free pointer |
-| `reaper.py` | Removes one run, including the three steps IPA's skills refuse to take |
-| `sweep.py` | Cross-run discovery and namespace-scoped teardown |
-| `tests/` | The free regression suite for every module above. The **directory** is what marks these free — which is why they need no marker and no name prefix |
-
-### Root
-
-| File | Role |
-|---|---|
-| `Makefile` | The umbrella. Delegates to scenarios; owns `preflight`, `list-stacks`, `teardown-namespace`, `test-offline` |
-| `DECISIONS.md` | **Read this.** Every measured figure and every defect found, with line cites |
diff --git a/internal/integration-tests/compose/Makefile b/internal/integration-tests/compose/Makefile
deleted file mode 100644
index a4c260c..0000000
--- a/internal/integration-tests/compose/Makefile
+++ /dev/null
@@ -1,76 +0,0 @@
-# The compose scenario — drives /ipa-init then /ipa-compose, and stops.
-#
-# Bare `make` prints help and creates nothing.
-
-# Derived from MAKEFILE_LIST rather than from the caller's cwd, so `make -C compose`
-# works from anywhere.
-HERE := $(dir $(lastword $(MAKEFILE_LIST)))
-ROOT := $(abspath $(HERE)..)
-
-# VERBOSE trap, and it is worth restating because the obvious form is wrong:
-#
-# WRONG: $(if $(VERBOSE),-vv -s,)
-#
-# Make's $(if) branches on NON-EMPTINESS, not truth. `VERBOSE=0` is non-empty, so that
-# form turns verbosity ON when a user asked for it off. `filter-out` against a list of
-# disabling words is what actually tests the value.
-OFF := 0 no false off
-VERBOSE ?= 1
-PYTEST_FLAGS := $(if $(filter-out $(OFF),$(VERBOSE)),-vv -s -rs --log-cli-level=INFO,-q)
-
-# `python -m pytest`, NOT bare `pytest`, and this is the load-bearing detail of the
-# whole layout. Running from $(ROOT) is what makes `harness` importable — but only
-# because `-m` places the working directory on sys.path. The bare `pytest` console
-# script does NOT, and every test module then fails with
-# `ModuleNotFoundError: No module named 'harness'`. Measured, not assumed.
-#
-# This is why no `[build-system]`, no editable install and no `pythonpath` entry are
-# needed: the interpreter's working directory is the package's parent.
-PYTEST ?= uv run python -m pytest
-
-.DEFAULT_GOAL := help
-
-.PHONY: help test test-ci clean
-
-help:
- @echo ""
- @echo "compose scenario — /ipa-init then /ipa-compose. Stops before /ipa-prepare."
- @echo ""
- @echo " make test Drive the agent to /ipa-compose. (MEASURED: ~\$$0.94, ~6m20s)"
- @echo " Creates ONE real {ns}-dev-security stack (2 IAM roles:"
- @echo " free, but real). No billable infrastructure."
- @echo ""
- @echo " make test-ci Same drive, but unmet prerequisites FAIL instead of"
- @echo " skipping, and results are written to junit-compose.xml."
- @echo ""
- @echo " make clean Remove this scenario's caches."
- @echo ""
- @echo " Currently RED — /ipa-compose generates none of its eight artifacts."
- @echo " See README.md and DECISIONS.md F3 before reading a failure as a regression."
- @echo ""
- @echo " Remove what a run leaves standing, from the harness root:"
- @echo " make teardown-namespace NAMESPACE="
- @echo ""
-
-# IPA_IT_MAX_BUDGET_USD is set INLINE on the recipe line, never as `export` and never as
-# a file-level variable. The two scenarios' ceilings differ threefold, and an ambient
-# value would apply to every recipe here and to anything else run on the same runner —
-# so this scenario's $5 ceiling could cap a deploy run and stop it mid-deployment, which
-# is indistinguishable from a wedge and leaves partial infrastructure standing.
-test:
- cd "$(ROOT)" && IPA_IT_MAX_BUDGET_USD=5 $(PYTEST) $(PYTEST_FLAGS) \
- -c compose/pytest.ini compose/ -m integration
-
-# IPA_IT_STRICT_PREFLIGHT=1 is inline here and on no other line. In CI an unmet
-# prerequisite must FAIL rather than skip: a build that skips every billable test exits 0
-# having tested nothing, which reads as a pass.
-#
-# --junitxml names THIS scenario's report, so two scenarios' results stay distinguishable
-# and the pipeline's junit-*.xml glob collects whichever ran.
-test-ci:
- cd "$(ROOT)" && IPA_IT_MAX_BUDGET_USD=5 IPA_IT_STRICT_PREFLIGHT=1 \
- $(PYTEST) $(PYTEST_FLAGS) -c compose/pytest.ini compose/ \
- -m integration --junitxml=junit-compose.xml
-
-clean:
- rm -rf $(HERE).pytest_cache $(HERE)__pycache__
diff --git a/internal/integration-tests/compose/README.md b/internal/integration-tests/compose/README.md
deleted file mode 100644
index e1f1a39..0000000
--- a/internal/integration-tests/compose/README.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# compose scenario
-
-Drives `/ipa-init` then `/ipa-compose`, and **stops before `/ipa-prepare`**. The stop is
-the point: a composition defect found here costs about a dollar, and the same defect
-found by the `deploy` scenario costs about fifteen.
-
-```bash
-make test # the drive
-make -C ../ teardown-namespace NAMESPACE= # remove what it left standing
-```
-
-## Status: was RED on F3; the cause is fixed and this has not been re-run
-
-This scenario's last run failed for a cause **outside itself**: `/ipa-compose` generated
-none of its eight artifacts, because nothing brought the agent back from `/ipa-security`
-(`DECISIONS.md` F3). The skills now state the continuation obligation at every delegation
-boundary, and the drive nudges when the artifacts are still absent.
-
-Two things to read on the next run, in this order:
-
-1. **`nudges=` in the printed drive line.** Zero means the skills carried themselves
- through. Above zero means one still ends its turn early and the harness pushed it —
- the run may pass, and the defect is real.
-2. **`test_the_answer_channel_is_recorded`.** Until 2026-08-18 `/ipa-init` batched five
- questions with a one-option namespace question, which `AskUserQuestion` rejects
- outright, so no batch was ever answered and `harness.answers` went unused. That
- assertion is what notices.
-
-## Cost: $0.9417 over 377.8 s — measured
-
-**Measured, not estimated.** Recorded in `DECISIONS.md` Phase 3 from an actual run. The
-figure covers model spend for the drive.
-
-The ceiling is `IPA_IT_MAX_BUDGET_USD`, default 5, set **inline** on this scenario's
-recipe lines so it cannot leak into a deploy run on the same machine. The deploy
-scenario has its own, separate ceiling for the same reason.
-
-## What it creates
-
-One real stack: `{ns}-dev-security` — two named IAM roles.
-
-They are **free but real**. The scenario is described as creating no billable
-infrastructure, which is true and easy to over-read as creating nothing at all. Left
-alone, it is one role pair per iteration.
-
-Remove them from the harness root:
-
-```bash
-make teardown-namespace NAMESPACE=
-```
-
-The namespace is printed by the run and recorded in
-`standing-resources-.md` at the harness root.
-
-## What it asserts
-
-Grouped by what each group would otherwise miss silently:
-
-- **The run reached compose and stopped.** No ceiling stopped it; `scripts/deploy.mk`
- exists; nothing was deployed beyond the security stack.
-- **The harness's answers reached the skills.** `APP_NAMESPACE` equalling the generated
- namespace is the *only* discriminator — every other pinned answer is also the skill's
- own recommended option, so agreement on those is consistent with no answer having been
- delivered at all.
-- **All eight artifacts exist.** The claim is the skill's own and had never been observed
- on a real run.
-- **The composed architecture.** Tier set, per-tier prepare targets, the cross-tier SQS
- wiring, and teardown being the exact reverse of deploy.
-- **The two costly defects.** `build.mk` referencing `web-client/` rather than a
- non-existent `frontend/`, and `env.mk` declaring both `update-env-*` targets — whose
- absence is *fully* silent: the deployment succeeds and the frontend is served a config
- naming no API and no identity provider, failing in the browser with nothing in any
- deployment log.
-
-## Files
-
-Five, and this is the set a new scenario needs:
-
-| File | Holds |
-|---|---|
-| `Makefile` | `help` (default), `test`, `test-ci`, `clean` |
-| `pytest.ini` | this scenario's `integration` marker, plus the shared-fixtures plugin |
-| `conftest.py` | `compose_run` — the drive, and the only thing here that spends |
-| `README.md` | this file |
-| `test_compose.py` | the assertions |
-
-Everything else — `credentials`, `resolver`, `namespace`, `transcript`, `temp_project` —
-comes from `harness.fixtures`, loaded as a plugin by `pytest.ini`'s `-p` flag rather than
-copied.
diff --git a/internal/integration-tests/compose/conftest.py b/internal/integration-tests/compose/conftest.py
deleted file mode 100644
index 107214b..0000000
--- a/internal/integration-tests/compose/conftest.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""The compose scenario's own drive. Everything else comes from ``harness.fixtures``.
-
-``compose_run`` is defined HERE rather than in the shared harness because the drive's
-scope and prompt are what make this scenario what it is -- a scenario is its drive. Put
-it in the shared layer and the shared layer would have to know about each scenario,
-which inverts the dependency the package exists to create.
-
-It is also **the thing in this folder that spends money**, which is why it is read in the
-same directory as the README stating what that costs.
-"""
-
-from __future__ import annotations
-
-import asyncio
-
-import pytest
-
-from harness import driver, lifecycle
-from harness.fixtures import _unmet
-
-
-@pytest.fixture(scope="session")
-def compose_run(temp_project, resolver, transcript):
- """Drive the agent as far as ``/ipa-compose``'s completion report."""
- _unmet("bedrock_driver_model")
- options = driver.build_options(temp_project.root, resolver, transcript)
- result = asyncio.run(
- driver.drive(
- driver.COMPOSE_PROMPT,
- temp_project.root,
- resolver,
- transcript,
- options,
- # What "finished" means lives in harness.lifecycle: the artifacts on disk,
- # never the run's own report. ``subtype='success'`` is what the SDK says
- # about a turn that ended, and compose has twice ended its turn at Step 1.5
- # -- after delegating to /ipa-security -- with every artifact ungenerated.
- is_complete=lambda _r: lifecycle.compose_state(temp_project.root),
- )
- )
- # Printed because a drive of this length has no other progress output, and a wedged
- # run with a blank screen is indistinguishable from a slow one.
- print(
- f"\ncompose drive: cost=${result.cost_usd} turns={result.turns} "
- f"wall={result.wall_clock_s:.0f}s channels={sorted(result.channels_used)} "
- f"nudges={result.nudges}"
- )
- return result
diff --git a/internal/integration-tests/compose/pytest.ini b/internal/integration-tests/compose/pytest.ini
deleted file mode 100644
index d69376b..0000000
--- a/internal/integration-tests/compose/pytest.ini
+++ /dev/null
@@ -1,25 +0,0 @@
-[pytest]
-testpaths = .
-
-# Registered with wording specific to THIS scenario, not the generic "costs money".
-# What it costs and what it creates differ per scenario, and the marker description is
-# where a reader looks before running one.
-#
-# The `teardown` marker is deliberately NOT registered here: this scenario has no
-# teardown assertions, and with --strict-markers an unused registration would let
-# `-m teardown` silently select nothing rather than erroring.
-markers =
- integration: drives the agent to /ipa-compose only. Model spend, plus one real
- {ns}-{env}-security stack (two named IAM roles — free but real). No billable
- infrastructure.
-
-# -p loads the shared fixtures as a PLUGIN, so neither scenario copies them and neither
-# imports from the other's conftest. It has to be a plugin rather than an import because
-# harness.fixtures also supplies the pytest_runtest_makereport hook, and pytest collects
-# hooks only from conftest files and plugins.
-#
-# --import-mode=importlib is what keeps the layout extensible, and it is load-bearing
-# rather than stylistic: under pytest's default `prepend` mode, two scenarios each
-# holding a test_smoke.py collide on module name and one SILENTLY shadows the other —
-# collection succeeds and one module's assertions never run.
-addopts = --strict-markers --import-mode=importlib -p harness.fixtures
diff --git a/internal/integration-tests/compose/test_compose.py b/internal/integration-tests/compose/test_compose.py
deleted file mode 100644
index edcbca1..0000000
--- a/internal/integration-tests/compose/test_compose.py
+++ /dev/null
@@ -1,229 +0,0 @@
-"""Composition assertions, made against a run that stopped at ``/ipa-compose``.
-
-Each assertion is here because its absence is **silent**. Two of them pay for the
-whole phase by catching, at composition price, a defect that would otherwise surface
-twenty minutes into a build or not at all:
-
-* ``cd web-client`` in ``build.mk`` — the template may emit ``cd frontend``, which
- does not exist in this repository.
-* ``update-env-backend`` / ``update-env-frontend`` in ``env.mk`` — their absence is
- fully silent: the deployment succeeds and the frontend is served a config naming
- no API and no identity provider, failing in the browser with nothing in any
- deployment log.
-
-A failure here is recorded in ``DECISIONS.md`` with its line cite and **blocks
-Phase 4**, because ``.claude/skills/**`` and ``MAKEFILE_TEMPLATES.md`` are public
-interface and each fix is its own change with a bumping commit type.
-"""
-
-from __future__ import annotations
-
-import pytest
-
-from harness import artifacts, aws, standing
-
-pytestmark = pytest.mark.integration
-
-
-EXPECTED_TIERS = {"frontend", "backend", "queue"}
-EXPECTED_PREPARE_TARGETS = ("prepare-logs", "prepare-ecr", "prepare-cognito")
-
-
-@pytest.fixture(scope="module")
-def project(compose_run, temp_project):
- """The composed temp project. Depends on ``compose_run`` so the drive happens."""
- return temp_project.root
-
-
-@pytest.fixture(scope="module")
-def deploy_mk(project) -> str:
- return artifacts.read(project, "scripts/deploy.mk")
-
-
-# --------------------------------------------------------------------------- #
-# The run reached compose and stopped
-# --------------------------------------------------------------------------- #
-
-
-def test_the_run_reached_the_compose_completion_report(compose_run, project):
- """The economic argument for this phase is that composition creates no billable
- resources beyond two free IAM roles."""
- assert not compose_run.stopped_by_ceiling, (
- f"a ceiling stopped the run rather than the agent finishing: "
- f"cost=${compose_run.cost_usd} turns={compose_run.turns}"
- )
- assert (project / "scripts" / "deploy.mk").is_file(), (
- "compose did not reach artifact generation; see the transcript's verbatim "
- "gate text for where it stopped"
- )
-
-
-def test_the_run_stopped_before_prepare(project):
- """No prepare or deploy step ran.
-
- Their side effects are what this asserts on, not the absence of a log line: a
- deployed stack under the namespace other than security would mean the stop
- instruction was not honoured.
- """
- # Asserted against AWS in test_no_unexpected_stacks_exist below; here we check
- # the local evidence that no deployment was attempted.
- assert not (project / ".ipa-deployed").exists()
-
-
-def test_the_answer_channel_is_recorded(compose_run):
- """Which path delivered each batch, so an SDK channel switch appears as a channel
- change rather than as a wrong namespace three steps later."""
- assert compose_run.channels_used, "no question batch was recorded as answered"
- assert compose_run.channels_used <= {"pre_tool_use_hook", "can_use_tool"}
-
-
-# --------------------------------------------------------------------------- #
-# THE discriminator
-# --------------------------------------------------------------------------- #
-
-
-def test_env_namespace_equals_the_generated_namespace(project, namespace):
- """The ONLY discriminator between "the harness answered" and "the agent used
- skill defaults".
-
- ``AWS_REGION=us-east-1``, ``APP_ENV=dev`` and ``APP_IAC=cloudformation`` are each
- themselves ``/ipa-init``'s recommended option, so agreement on any of them is
- consistent with **no answer having been delivered at all**. Pinning ``us-east-1``
- was chosen deliberately to keep that property intact, which makes this assertion
- the one that carries it.
- """
- env_file = project / ".env"
- assert env_file.is_file(), "/ipa-init did not write .env"
- values = standing.parse_env(env_file)
-
- assert values.get("APP_NAMESPACE") == namespace, (
- f"APP_NAMESPACE is {values.get('APP_NAMESPACE')!r}, expected {namespace!r}. "
- "If it is 'app', the skill's default won and the answers never reached it."
- )
-
-
-def test_env_carries_a_real_aws_profile(project):
- """'Skip' is the RECOMMENDED option for AWS_PROFILE and omits the line entirely,
- while four downstream skills STOP without it.
-
- Compared against ``aws.profile_for_env()`` -- the value the HARNESS SUPPLIED -- not
- against anything describing how the harness authenticated. Those are now separate
- values: the harness resolves credentials from the default chain, so what it could
- report about itself is a resolution method like ``sso``, which is never a profile
- name and would never match.
- """
- values = standing.parse_env(project / ".env")
- assert values.get("AWS_PROFILE") == aws.profile_for_env()
-
-
-def test_env_pins_the_region_and_engine(project):
- values = standing.parse_env(project / ".env")
- assert values.get("AWS_REGION") == "us-east-1"
- assert values.get("APP_ENV") == "dev"
- assert values.get("APP_IAC") == "cloudformation"
-
-
-# --------------------------------------------------------------------------- #
-# All eight artifacts
-# --------------------------------------------------------------------------- #
-
-
-def test_all_eight_artifacts_exist(project):
- """The claim that all eight are generated is the skill's own
- (``ipa-compose/SKILL.md:266-274``) and had never been observed on a real run."""
- missing = artifacts.missing_artifacts(project)
- assert missing == [], f"/ipa-compose did not generate: {missing}"
-
-
-# --------------------------------------------------------------------------- #
-# The composed architecture
-# --------------------------------------------------------------------------- #
-
-
-def test_deploy_mk_header_tier_set(deploy_mk):
- tiers = artifacts.header_tiers(deploy_mk)
- assert tiers == EXPECTED_TIERS, (
- f"composed tiers are {sorted(tiers)}, expected {sorted(EXPECTED_TIERS)}; a "
- "composition that omits a tier generates artifacts that succeed at "
- "everything they attempt while deploying a different architecture"
- )
-
-
-@pytest.mark.parametrize("target", EXPECTED_PREPARE_TARGETS)
-def test_prepare_mk_declares_each_auto_included_tier(project, target):
- text = artifacts.read(project, "scripts/prepare.mk")
- assert target in artifacts.targets(text), (
- f"{target} missing; the auto-include resolver did not add its tier"
- )
-
-
-def test_deploy_backend_enables_the_sqs_integration(deploy_mk):
- """The ONLY artifact-level proof that Step 3.4's cross-tier auto-wiring fired.
-
- Without it the tiers deploy independently and the failure appears only at
- runtime, as a job accepted and never processed.
- """
- params = artifacts.parameter_overrides(deploy_mk, "deploy-backend")
- assert params.get("EnableSqsIntegration") == "true", (
- f"deploy-backend parameter overrides are {params}; EnableSqsIntegration=true "
- "is absent, so the queue was composed but never wired to the backend"
- )
-
-
-def test_teardown_is_the_exact_reverse_of_deploy(deploy_mk):
- """Compares tier SUFFIXES: ``MAKEFILE_TEMPLATES.md:54`` generates ``deploy-{s}``
- and ``:162`` generates ``teardown-{s}``, so the two lists never share a name."""
- ok, dep, tear = artifacts.teardown_is_reverse_of_deploy(deploy_mk)
- assert ok, f"deploy: {dep}\nteardown: {tear}\nteardown must be the exact reverse"
-
-
-# --------------------------------------------------------------------------- #
-# The two costly defects
-# --------------------------------------------------------------------------- #
-
-
-def test_build_mk_references_web_client_not_frontend(project):
- """Defect 4. ``cd frontend`` does not exist in this repository, and undetected it
- fails twenty minutes into a build with an error about a missing directory."""
- text = artifacts.read(project, "scripts/build.mk")
- assert "cd frontend" not in text, (
- "build.mk emits `cd frontend`, which does not exist here; the frontend tree "
- "is web-client/"
- )
- assert "web-client" in text, "build.mk does not reference web-client/ at all"
-
-
-def test_build_mk_declares_exactly_one_container_build_target(project):
- text = artifacts.read(project, "scripts/build.mk")
- count = artifacts.target_count(text, "build-rest-lambda")
- assert count == 1, (
- f"build-rest-lambda is declared {count} times; a duplicate either builds "
- "twice or builds the wrong one depending on ordering"
- )
-
-
-@pytest.mark.parametrize("target", ["update-env-backend", "update-env-frontend"])
-def test_env_mk_declares_both_sync_targets(project, target):
- """Defect 5. Absence is fully silent: the deployment succeeds and the frontend is
- served a config naming no API and no identity provider, failing in the browser
- with nothing in any deployment log."""
- text = artifacts.read(project, "scripts/env.mk")
- assert target in artifacts.targets(text), f"env.mk does not declare {target}"
-
-
-# --------------------------------------------------------------------------- #
-# The account
-# --------------------------------------------------------------------------- #
-
-
-def test_no_unexpected_stacks_exist(namespace):
- """Only ``{ns}-{env}-security`` may exist. Route A deploys it -- two named IAM
- roles, free but real -- so this phase is NOT strictly zero-AWS."""
- stacks, error = standing.live_stacks(namespace, aws.REGION)
- assert not error, f"could not list stacks: {error}"
-
- unexpected = [n for n, _ in stacks if not n.endswith("-security")]
- assert unexpected == [], (
- f"the stop-after-compose instruction was not honoured; these stacks were "
- f"deployed: {unexpected}"
- )
diff --git a/internal/integration-tests/deploy/Makefile b/internal/integration-tests/deploy/Makefile
deleted file mode 100644
index 895747b..0000000
--- a/internal/integration-tests/deploy/Makefile
+++ /dev/null
@@ -1,102 +0,0 @@
-# The deploy scenario — the FULL lifecycle. BILLABLE.
-#
-# Bare `make` prints help and creates nothing.
-
-# Derived from MAKEFILE_LIST rather than from the caller's cwd, so `make -C deploy`
-# works from anywhere.
-HERE := $(dir $(lastword $(MAKEFILE_LIST)))
-ROOT := $(abspath $(HERE)..)
-
-# VERBOSE trap, and it is worth restating because the obvious form is wrong:
-#
-# WRONG: $(if $(VERBOSE),-vv -s,)
-#
-# Make's $(if) branches on NON-EMPTINESS, not truth. `VERBOSE=0` is non-empty, so that
-# form turns verbosity ON when a user asked for it off. `filter-out` against a list of
-# disabling words is what actually tests the value.
-#
-# Verbose is the DEFAULT on purpose here especially. This run measured 32 minutes
-# (2026-08-19), and per-test output is the only way to distinguish progress from a wedge —
-# a wedged half-hour billable run with nothing on screen is not recoverable by re-running
-# it.
-OFF := 0 no false off
-VERBOSE ?= 1
-PYTEST_FLAGS := $(if $(filter-out $(OFF),$(VERBOSE)),-vv -s -rs --log-cli-level=INFO,-q)
-
-# `python -m pytest`, NOT bare `pytest`. Running from $(ROOT) is what makes `harness`
-# importable — but only because `-m` places the working directory on sys.path. The bare
-# `pytest` console script does NOT, and every test module then fails with
-# `ModuleNotFoundError: No module named 'harness'`. Measured, not assumed.
-PYTEST ?= uv run python -m pytest
-
-.DEFAULT_GOAL := help
-
-.PHONY: help test test-teardown test-ci clean
-
-help:
- @echo ""
- @echo "deploy scenario — FULL lifecycle: /ipa-init, /ipa-compose, /ipa-prepare, /ipa-deploy."
- @echo ""
- @echo " make test Deploy SEVEN stacks and LEAVE THEM STANDING, so you can"
- @echo " open the app in a browser and confirm by eye what the"
- @echo " assertions claim. CloudFront + ECR + 2 Lambdas + SQS +"
- @echo " DynamoDB + S3. (BILLABLE — cost UNMEASURED)"
- @echo ""
- @echo " make test-teardown Verify-then-remove this run's stacks. Needs BOTH the"
- @echo " 'teardown' marker and IPA_IT_TEARDOWN=1."
- @echo ""
- @echo " make test-ci Deploy THEN tear down, in ONE pytest run, with unmet"
- @echo " prerequisites failing instead of skipping."
- @echo " (BILLABLE, then frees it)"
- @echo ""
- @echo " make clean Remove this scenario's caches."
- @echo ""
- @echo " Cost is UNMEASURED, not estimated — this run has never completed. No figure"
- @echo " is given rather than a predicted one. See README.md."
- @echo ""
- @echo " Both prior runs stopped at COMPOSITION — /ipa-compose generated none of its"
- @echo " eight artifacts (DECISIONS.md F3, F8). Fixed in the skills; NOT re-run since,"
- @echo " so the deployment assertions remain unexercised. A truncated lifecycle now"
- @echo " fails ONCE, in deploy_run, naming what is missing."
- @echo ""
- @echo " Remove what a run leaves standing, from the harness root:"
- @echo " make teardown-namespace NAMESPACE="
- @echo ""
-
-# IPA_IT_FULL_MAX_BUDGET_USD is set INLINE, never as `export` and never as a file-level
-# variable. This scenario's ceiling is 3x the compose scenario's; an ambient value would
-# apply to every recipe here and to anything else on the same runner.
-#
-# NOTE: no teardown opt-in on this line. `test` LEAVES THE STACKS STANDING, deliberately
-# — that is what lets a maintainer confirm by eye what the assertions claim.
-test:
- cd "$(ROOT)" && IPA_IT_FULL_MAX_BUDGET_USD=$${IPA_IT_FULL_MAX_BUDGET_USD:-15} \
- $(PYTEST) $(PYTEST_FLAGS) -c deploy/pytest.ini deploy/ -m integration
-
-# Both teardown opt-ins are set INLINE on this recipe line — never as `export`, never as
-# a file-level variable, never as a pipeline variable. An ambient variable applies to
-# every phase of every build, which is precisely the "persists invisibly" case the
-# two-part opt-in exists to prevent — and declaring it ambiently defeats the mechanism
-# SILENTLY, because the invocation that needs it still works and the failure only appears
-# the day someone runs another target on the same runner.
-test-teardown:
- cd "$(ROOT)" && IPA_IT_TEARDOWN=1 \
- $(PYTEST) $(PYTEST_FLAGS) -c deploy/pytest.ini deploy/ -m teardown
-
-# ONE pytest invocation with the teardown marker NOT deselected, so deploy and teardown
-# share a namespace as a property of the session fixture rather than of a hand-off.
-#
-# TWO invocations would generate two namespaces, and the second would tear down a
-# namespace that never existed — which finds nothing, removes nothing, and PASSES,
-# leaving the deployment standing and billing while reporting that it was removed.
-#
-# IPA_IT_STRICT_PREFLIGHT=1 because in CI an unmet prerequisite must FAIL rather than
-# skip: a build that skips every billable test exits 0 having tested nothing.
-test-ci:
- cd "$(ROOT)" && IPA_IT_STRICT_PREFLIGHT=1 IPA_IT_TEARDOWN=1 \
- IPA_IT_FULL_MAX_BUDGET_USD=$${IPA_IT_FULL_MAX_BUDGET_USD:-15} \
- $(PYTEST) $(PYTEST_FLAGS) -c deploy/pytest.ini deploy/ \
- -m "integration or teardown" --junitxml=junit-deploy.xml
-
-clean:
- rm -rf $(HERE).pytest_cache $(HERE)__pycache__
diff --git a/internal/integration-tests/deploy/README.md b/internal/integration-tests/deploy/README.md
deleted file mode 100644
index 55a379c..0000000
--- a/internal/integration-tests/deploy/README.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# deploy scenario
-
-Drives the full lifecycle — `/ipa-init`, `/ipa-compose`, `/ipa-prepare`, `/ipa-deploy` —
-then asserts against the **live** deployment.
-
-```bash
-make test # deploy; LEAVES THE STACKS STANDING
-make test-teardown # verify-then-remove this run's stacks
-make test-ci # deploy THEN tear down, in one run
-```
-
-## Cost: $4.31 / 32 minutes — measured
-
-Measured once, on 2026-08-19: **`$4.3093221`, 98 turns, 1919.9 s**, completing the whole
-lifecycle with zero nudges (`DECISIONS.md` Phase 8). Plus AWS charges for seven stacks for
-as long as they stand.
-
-**Budget the `$15` ceiling, not the `$4.31`.** One run on one account in one region is a
-data point, not a distribution — a slower path through the same skills spends more, and the
-ceiling is what this scenario is permitted to spend before it is stopped.
-
-The ceiling is `IPA_IT_FULL_MAX_BUDGET_USD`, default 15, set **inline** on this
-scenario's recipe lines.
-
-It is **separate** from `IPA_IT_MAX_BUDGET_USD` (default 5, the compose scenario's) and
-must stay separate. The two differ threefold: one shared variable set for compose would
-cap a deploy run at $5 and stop it partway through a deployment — indistinguishable from
-a wedge, and leaving partial infrastructure standing.
-
-## The seven stacks
-
-Deployed under the run's generated namespace, in this order:
-
-| Stack | Holds |
-|---|---|
-| `{ns}-dev-logs` | centralised S3 log bucket |
-| `{ns}-dev-ecr` | container image repository |
-| `{ns}-dev-cognito` | user pool, OAuth hosted UI |
-| `{ns}-dev-security` | two named IAM roles |
-| `{ns}-dev-queue` | SQS + DLQ + worker Lambda + DynamoDB |
-| `{ns}-dev-backend` | Lambda + API Gateway v2 + DynamoDB |
-| `{ns}-dev-frontend` | S3 + CloudFront + OAC |
-
-### They are LEFT STANDING by default
-
-`make test` does not remove them, and that is deliberate rather than an oversight: a
-standing deployment is what lets a maintainer open the application in a browser and
-confirm by eye what the assertions claim.
-
-Remove them either way:
-
-```bash
-make test-teardown # verify-then-remove, from here
-make -C .. teardown-namespace NAMESPACE= # by namespace, from the harness root
-```
-
-The namespace is printed by the run and recorded in `standing-resources-.md` at the
-harness root, which is readable with no credentials configured.
-
-## Status: F3's cause is fixed; this scenario has not been re-run
-
-Both of this scenario's runs stopped at composition (`DECISIONS.md` F3 and F8): the agent
-delegated to `/ipa-security`, deployed the security stack, and ended its turn with none of
-`/ipa-compose`'s eight artifacts generated. The skills now state the continuation
-obligation explicitly at every delegation boundary, and the drive nudges a bounded number
-of times when the lifecycle is unfinished.
-
-**The re-run on 2026-08-19 measured `nudges=0`** — all eight artifacts, all seven stacks,
-the lifecycle carried by the skills themselves. `nudges=` in the drive's printed line stays
-the number to read first: **zero is the only good value**, and anything above it is a skill
-that still ends its turn early and was pushed through by the harness.
-
-One assertion failed, in the test rather than in IPA: the queue round trip posted a body
-`JobCreate` never accepted and drew a 422 (F11, fixed). So **the queue traversal is still
-unproven** — the job was never created, nothing was enqueued, and no status transition has
-ever been observed. Every other queue assertion passed, so the infrastructure is verified
-and the trip through it is not.
-
-## What it asserts
-
-Three assertions carry **stated limits**, and each limit is in the test's own docstring so
-a later reader cannot over-read it:
-
-- The 401 check **cannot** establish that the container runs.
-- The container probe is what establishes that, and needs three conditions.
-- The job check stops at "left `PENDING`" — **a `FAILED` job passes it.**
-
-Beyond those: every expected stack exists in a healthy completed state; endpoint
-locations come from CloudFormation **outputs** rather than from `.env` (so one missing
-`update-env-*` target fails the check that owns it instead of blanking every downstream
-probe); the app is served with its runtime config; an anonymous API call is refused with
-exactly 401; a real Cognito token is accepted; the DLQ is empty; and no generated
-credential reaches a durable sink.
-
-The teardown assertions add: the deployment is **verified live before anything is
-removed** — ordering, not coverage, because otherwise a run that provisioned nothing and
-a run that provisioned everything both end with a passing teardown; nothing remains under
-this namespace; **no other run was removed**, checked against a listing that includes a
-sentinel foreign namespace, because "nothing remains under this namespace" is also
-satisfied by deleting the entire account; and the standing-resource pointer is removed
-only for a confirmed deletion.
-
-## Files
-
-Six — the five a scenario needs, plus a second test module:
-
-| File | Holds |
-|---|---|
-| `Makefile` | `help` (default), `test`, `test-teardown`, `test-ci`, `clean` |
-| `pytest.ini` | this scenario's `integration` and `teardown` markers, plus the plugin |
-| `conftest.py` | `deploy_run` — session-scoped, and the only thing here that spends |
-| `README.md` | this file |
-| `test_deploy.py` | the live-deployment assertions |
-| `test_teardown.py` | verify-then-remove, behind the two-part opt-in |
-
-Everything else — `credentials`, `resolver`, `namespace`, `transcript`, `temp_project` —
-comes from `harness.fixtures`, loaded as a plugin by `pytest.ini`'s `-p` flag.
-
-`deploy_run` is **session**-scoped. At module scope it would drive twice, doubling spend
-and standing infrastructure, and the teardown would then remove a different deployment
-than the assertions verified — passing while the verified deployment stays up and billing.
diff --git a/internal/integration-tests/deploy/conftest.py b/internal/integration-tests/deploy/conftest.py
deleted file mode 100644
index 55f0bc6..0000000
--- a/internal/integration-tests/deploy/conftest.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""The deploy scenario's own drive. Everything else comes from ``harness.fixtures``.
-
-``deploy_run`` is the fixture ``test_deploy.py`` has always requested and that nothing
-ever defined. Its absence did not make this tier merely unrun -- it made it
-**uncollectable**: `--setup-plan` failed with eight setup errors before any preflight
-probe executed, so the failure named neither a missing credential nor a stopped daemon.
-That stayed invisible because the routine free target deselects these markers.
-
-**The completion gate is the other thing this file exists for.** A drive that ends its
-turn early reports ``ResultMessage(subtype='success')``, and every assertion downstream
-then fails on its own terms -- ``describe_stacks`` on a stack nothing created,
-twenty-one times, in a ``botocore`` traceback that mentions neither ``/ipa-compose`` nor
-the turn that ended. Measured 2026-08-18: fifteen failures and six errors, not one of
-which named the cause. The gate turns that into one message, before any test runs, at
-the point where the run's own artifacts are the evidence.
-
-What "finished" MEANS lives in ``harness.lifecycle``, not here: a scenario conftest is
-unreachable from the offline suite, and the predicate that decides whether a billable run
-is re-queried is not something to leave untested.
-"""
-
-from __future__ import annotations
-
-import asyncio
-
-import pytest
-
-from harness import aws, driver, lifecycle
-from harness.fixtures import _unmet
-
-#: The environment every stack name carries. ``harness.fixtures`` uses the same value
-#: for the standing-resource report.
-ENV = "dev"
-
-
-@pytest.fixture(scope="session")
-def deploy_run(temp_project, resolver, transcript, namespace):
- """FULL LIFECYCLE. Creates billable AWS resources and LEAVES THEM STANDING.
-
- **Session scope is required, not preferred.** At module scope this drives twice --
- once for ``test_deploy.py`` and once for ``test_teardown.py`` -- which doubles both
- the spend and the standing infrastructure. Worse, the teardown would then remove the
- *second* deployment while the assertions verified the *first*, so the run reports a
- successful verify-then-remove while the deployment it actually checked stays up and
- billing. That is the exact failure the verify-before-remove ordering exists to
- prevent, reintroduced through fixture scope instead of through invocation count.
-
- The session-scoped ``resolver`` in ``harness.fixtures`` is the matching half: it is
- what makes deploy and teardown share one namespace without threading it.
-
- The gate at the end **fails this fixture rather than each assertion**, so a
- truncated lifecycle reports once and accurately. It is safe to fail here:
- ``test_teardown.py`` depends on ``namespace`` and ``temp_project``, never on this
- fixture, so the teardown path and the standing-resource report both still run.
- """
- _unmet("bedrock_driver_model", "docker_daemon", "npm_present")
- options = driver.full_lifecycle_options(temp_project.root, resolver, transcript)
- result = asyncio.run(
- driver.drive(
- driver.FULL_PROMPT,
- temp_project.root,
- resolver,
- transcript,
- options,
- is_complete=lambda _r: lifecycle.deploy_state(
- temp_project.root, namespace, ENV, aws.REGION
- ),
- )
- )
- # Printed because a run of this length has no other progress output, and a wedged
- # twenty-minute billable run with a blank screen is not recoverable by re-running it.
- print(
- f"\ndeploy drive: cost=${result.cost_usd} turns={result.turns} "
- f"wall={result.wall_clock_s:.0f}s nudges={result.nudges}"
- )
-
- assert not result.stopped_by_ceiling, (
- f"a ceiling stopped the run rather than the agent finishing: "
- f"cost=${result.cost_usd} turns={result.turns}. Raising "
- "IPA_IT_FULL_MAX_BUDGET_USD is the wrong first move -- read the transcript "
- "for what it was doing when it ran out."
- )
-
- finished, missing = lifecycle.deploy_state(
- temp_project.root, namespace, ENV, aws.REGION, strict=True
- )
- assert finished, (
- f"the lifecycle did not complete, so the assertions below have nothing to "
- f"assert against: {missing}\n"
- f"nudges={result.nudges}, gates seen={sorted(set(result.gates_seen))}, "
- f"transcript={transcript.path}.\n"
- "A run that ends its turn mid-lifecycle reports subtype='success', which is "
- "why this is checked against disk and AWS rather than against the result."
- )
-
- # Not fatal, and stated anyway: the run completed, and it needed help to. Every
- # nudge is a skill that stopped where it should have continued.
- if result.nudges:
- print(
- f"\nthe lifecycle completed only after {result.nudges} nudge(s). "
- f"Last gap: {missing or '(none at the final check)'}. A skill ended its "
- "turn early -- see the transcript's 'nudge' events for where."
- )
-
- return result
diff --git a/internal/integration-tests/deploy/pytest.ini b/internal/integration-tests/deploy/pytest.ini
deleted file mode 100644
index 9c3438a..0000000
--- a/internal/integration-tests/deploy/pytest.ini
+++ /dev/null
@@ -1,21 +0,0 @@
-[pytest]
-testpaths = .
-
-# Both markers are registered here because this scenario has assertions for both. Their
-# wording is specific to THIS scenario — what it costs and what it leaves behind differ
-# per scenario, and the marker description is where a reader looks before running one.
-markers =
- integration: drives the FULL LIFECYCLE — /ipa-init, /ipa-compose, /ipa-prepare,
- /ipa-deploy. Creates SEVEN billable stacks and LEAVES THEM STANDING.
- teardown: deletes real AWS resources. Requires IPA_IT_TEARDOWN=1 as well as this
- marker — one opt-in is satisfiable by accident.
-
-# -p loads the shared fixtures as a PLUGIN, so neither scenario copies them and neither
-# imports from the other's conftest. It has to be a plugin rather than an import because
-# harness.fixtures also supplies the pytest_runtest_makereport hook, and pytest collects
-# hooks only from conftest files and plugins.
-#
-# --import-mode=importlib is load-bearing rather than stylistic: under pytest's default
-# `prepend` mode, two scenarios each holding a same-named test module collide and one
-# SILENTLY shadows the other — collection succeeds and one module's assertions never run.
-addopts = --strict-markers --import-mode=importlib -p harness.fixtures
diff --git a/internal/integration-tests/deploy/test_deploy.py b/internal/integration-tests/deploy/test_deploy.py
deleted file mode 100644
index b8f10c3..0000000
--- a/internal/integration-tests/deploy/test_deploy.py
+++ /dev/null
@@ -1,451 +0,0 @@
-"""Live assertions against a completed IPA deployment.
-
-These assert the **architecture**, not the run. A run that completed `/ipa-deploy`
-without error has established that the Makefiles ran — not that a user can load the
-application, that the API refuses an anonymous caller, that a real token is accepted,
-or that a submitted job reaches a worker.
-
-Three assertions carry stated limits, and each limit is in the test's own docstring
-so a later reader cannot over-read the assertion:
-
-* The 401 check **cannot** establish that the container runs.
-* The container probe is what establishes that, and needs three conditions.
-* The job check stops at "left `PENDING`", and **a `FAILED` job passes**.
-"""
-
-from __future__ import annotations
-
-import json
-import time
-
-import pytest
-import requests
-
-from harness import aws, cognito_auth, standing
-
-pytestmark = pytest.mark.integration
-
-# Both from ``harness.standing``, never restated here. ``deploy/conftest.py``'s
-# completion gate reads the same tuple, and a second copy would let the gate that
-# decides "the lifecycle finished" disagree with the assertions that check what it
-# produced -- silently, in both directions.
-HEALTHY = standing.HEALTHY_STATES
-EXPECTED_TIERS = standing.EXPECTED_TIERS
-
-JOB_POLL_TIMEOUT_S = 120
-JOB_POLL_INTERVAL_S = 5
-HTTP_TIMEOUT_S = 30
-
-
-# --------------------------------------------------------------------------- #
-# Fixtures
-# --------------------------------------------------------------------------- #
-
-
-@pytest.fixture(scope="module")
-def session():
- """Region pinned, credentials from the default chain -- one policy, in harness.aws."""
- return aws.session()
-
-
-@pytest.fixture(scope="module")
-def cfn(session):
- return session.client("cloudformation")
-
-
-def _outputs(cfn, stack_name: str) -> dict[str, str]:
- stack = cfn.describe_stacks(StackName=stack_name)["Stacks"][0]
- return {o["OutputKey"]: o["OutputValue"] for o in stack.get("Outputs", [])}
-
-
-@pytest.fixture(scope="module")
-def stacks(deploy_run, namespace):
- found, error = standing.live_stacks(namespace, aws.REGION)
- assert not error, f"could not list stacks: {error}"
- return dict(found)
-
-
-@pytest.fixture(scope="module")
-def app_url(cfn, namespace) -> str:
- """From CloudFormation OUTPUTS, never from ``.env``.
-
- ``.env`` is populated by ``env.mk``'s ``update-env-*`` targets, so reading from it
- makes one missing target fail every downstream probe with a blank URL — destroying
- the diagnostic value of every assertion after it instead of failing the one check
- that owns it.
- """
- out = _outputs(cfn, f"{namespace}-dev-frontend")
- url = out.get("DistributionUrl") or out.get("AppUrl") or out.get("CloudFrontUrl")
- assert url, f"frontend stack outputs carry no app URL: {sorted(out)}"
- return url.rstrip("/")
-
-
-@pytest.fixture(scope="module")
-def api_url(cfn, namespace) -> str:
- out = _outputs(cfn, f"{namespace}-dev-backend")
- url = out.get("ApiUrl") or out.get("ApiEndpoint") or out.get("HttpApiUrl")
- assert url, f"backend stack outputs carry no API URL: {sorted(out)}"
- return url.rstrip("/")
-
-
-@pytest.fixture(scope="module")
-def cognito_outputs(cfn, namespace) -> dict[str, str]:
- return _outputs(cfn, f"{namespace}-dev-cognito")
-
-
-@pytest.fixture(scope="module")
-def identity(cognito_outputs, transcript) -> cognito_auth.Identity:
- """The one write these assertions make: a throwaway user in the run's own pool.
-
- The pool's own deletion removes it, with no separate cleanup obligation.
- """
- ident = cognito_auth.mint_token(
- user_pool_id=cognito_outputs["UserPoolId"],
- client_id=cognito_outputs["UserPoolClientId"],
- region=aws.REGION,
- )
- # Register BEFORE anything can write it. The filter is keyed on the live value at
- # the writer, not applied by care at each write site.
- transcript.register_secret(ident.password)
- return ident
-
-
-@pytest.fixture(scope="module")
-def auth_headers(identity) -> dict[str, str]:
- return {"Authorization": f"Bearer {identity.id_token}"}
-
-
-def _get_with_transient_retries(url: str, **kwargs) -> requests.Response:
- """GET, retrying ONLY on transport failures and 5xx.
-
- The Lambda Web Adapter — the shim fronting the container's web server — briefly
- serves 5xx while that server starts, so a 5xx is genuinely transient. A 401 or 403
- is a decision the gateway has already made: retrying it six times at ten-second
- intervals spends the whole startup window re-learning the same answer and then
- reports a timeout instead of the real result.
- """
- last: Exception | None = None
- for attempt in range(6):
- try:
- response = requests.get(url, timeout=HTTP_TIMEOUT_S, **kwargs)
- except requests.RequestException as exc:
- last = exc
- time.sleep(10)
- continue
- if response.status_code >= 500:
- time.sleep(10)
- continue
- return response
- raise AssertionError(f"no usable response from {url} after 6 attempts: {last}")
-
-
-# --------------------------------------------------------------------------- #
-# How the run got here
-# --------------------------------------------------------------------------- #
-
-
-def test_the_answer_channel_is_recorded(deploy_run):
- """Which path delivered each question batch.
-
- Deliberately NOT part of ``conftest.py``'s completion gate. A deployment can be
- entirely correct while the harness's resolver was never consulted -- that is
- exactly what happened on 2026-08-18, when ``/ipa-init``'s five-question call was
- rejected by ``AskUserQuestion``'s input validation and the agent filled ``.env``
- in from the appended system prompt instead. Failing the gate on it would throw
- away a completed billable deployment over a fact about the harness; failing one
- assertion reports it and keeps the rest of the run's evidence.
-
- An empty set therefore means: the pinned answers in ``harness.answers`` did not
- apply to this run, and anything they encode -- the namespace excepted, which the
- system prompt also pins -- reflects the skills' own defaults.
- """
- assert deploy_run.channels_used, (
- "no question batch was recorded as answered, so the resolver never fired. "
- "Check the transcript for an AskUserQuestion InputValidationError: a call "
- "with more than 4 questions, or any question offering fewer than 2 options, "
- "is rejected before the hook can answer it."
- )
- assert deploy_run.channels_used <= {"pre_tool_use_hook", "can_use_tool"}
-
-
-# --------------------------------------------------------------------------- #
-# Stacks
-# --------------------------------------------------------------------------- #
-
-
-@pytest.mark.parametrize("tier", EXPECTED_TIERS)
-def test_expected_stack_exists_and_is_healthy(stacks, namespace, tier):
- """Presence alone is insufficient.
-
- A ``ROLLBACK_COMPLETE`` stack exists, holds resources, costs money, and is not a
- working deployment. ``UPDATE_ROLLBACK_COMPLETE`` IS accepted: it denotes a stack
- that reverted to a previously working configuration and is serving it, where
- ``ROLLBACK_COMPLETE`` denotes a creation that never succeeded.
- """
- name = f"{namespace}-dev-{tier}"
- assert name in stacks, f"{name} was not deployed; found {sorted(stacks)}"
- assert stacks[name] in HEALTHY, (
- f"{name} is {stacks[name]}, which is not a healthy completed state"
- )
-
-
-def test_exactly_seven_stacks_under_the_namespace(stacks, namespace):
- assert len(stacks) == 7, f"expected 7 stacks, found {len(stacks)}: {sorted(stacks)}"
-
-
-# --------------------------------------------------------------------------- #
-# The served application
-# --------------------------------------------------------------------------- #
-
-
-def test_the_spa_is_served_through_cloudfront(app_url):
- """A distribution serving a directory listing or an access-denied document also
- returns a response; only the content distinguishes those from a deployment."""
- response = _get_with_transient_retries(app_url)
- assert response.status_code == 200, f"{app_url} -> {response.status_code}"
- body = response.text.lower()
- assert " {response.status_code}"
- client_id = cognito_outputs["UserPoolClientId"]
- assert client_id in response.text, (
- f"config.js does not carry the deployed client id {client_id}; "
- f"update-env-frontend may not have run. Body: {response.text[:400]}"
- )
-
-
-# --------------------------------------------------------------------------- #
-# Auth layer 1: the gateway refuses an anonymous caller
-# --------------------------------------------------------------------------- #
-
-
-def test_unauthenticated_health_is_exactly_401(api_url):
- """A 403 FAILS, a 5xx FAILS, and no response FAILS.
-
- A 403 means a token was presented and rejected, or a resource policy denied the
- call — real misconfigurations with different fixes, and neither is the property
- being asserted. Accepting "any non-2xx" would let a resource-policy denial, a
- missing authorizer and a correct authorizer all pass identically.
-
- **This check cannot establish that the container runs.** ``app-lib`` lists
- ``/health`` in ``PUBLIC_PATHS`` (``app_lib/common/auth.py:24``), so this 401 comes
- from API Gateway's JWT authorizer in FRONT of the Lambda — which answers
- identically whether the container is healthy, crash-looping, or was never pushed.
- That guarantee belongs to ``test_the_backend_container_runs``.
- """
- response = _get_with_transient_retries(f"{api_url}/health")
- assert response.status_code == 401, (
- f"expected exactly 401, got {response.status_code}. A 403 means a token was "
- f"presented and rejected or a resource policy denied the call -- a different "
- f"fault. Body: {response.text[:300]}"
- )
-
-
-# --------------------------------------------------------------------------- #
-# Auth layer 2: a real token is accepted
-# --------------------------------------------------------------------------- #
-
-
-def test_authenticated_health_returns_ok(api_url, auth_headers):
- """Uses the ID token, not the access token: only the ID token carries
- ``aud = client_id``, unambiguous under both auth layers."""
- response = _get_with_transient_retries(f"{api_url}/health", headers=auth_headers)
- assert response.status_code == 200, (
- f"{api_url}/health with a valid ID token -> {response.status_code}: "
- f"{response.text[:300]}"
- )
- assert response.json().get("status") == "ok", response.text[:300]
-
-
-def test_authenticated_jobs_collection_is_reachable(api_url, auth_headers):
- response = _get_with_transient_retries(
- f"{api_url}/api/v1/jobs", headers=auth_headers
- )
- assert response.status_code == 200, (
- f"{api_url}/api/v1/jobs -> {response.status_code}: {response.text[:300]}"
- )
-
-
-# --------------------------------------------------------------------------- #
-# The container actually runs
-# --------------------------------------------------------------------------- #
-
-
-def test_the_backend_container_runs(session, cfn, namespace):
- """Requires ALL THREE of: no ``FunctionError``, payload ``statusCode`` 200, and
- body ``status`` ``"ok"``.
-
- Lambda returns invocation ``StatusCode`` 200 **even when the handler raised** —
- the raise is reported in ``FunctionError``, not in the status — so the status
- alone would pass for a container that exists and throws.
-
- **This probe, not the 401, is what proves the container runs.** It reaches the
- handler without a token precisely because ``app-lib`` treats ``/health`` as
- public; that looks like a gap in the probe and is in fact what makes it possible.
- """
- out = _outputs(cfn, f"{namespace}-dev-backend")
- function_name = (
- out.get("FunctionName")
- or out.get("RestLambdaFunctionName")
- or out.get("BackendFunctionName")
- )
- assert function_name, f"backend outputs carry no function name: {sorted(out)}"
-
- event = {
- "version": "2.0",
- "routeKey": "GET /health",
- "rawPath": "/health",
- "rawQueryString": "",
- "headers": {"host": "probe.invalid", "accept": "application/json"},
- "requestContext": {
- "http": {
- "method": "GET",
- "path": "/health",
- "protocol": "HTTP/1.1",
- "sourceIp": "127.0.0.1",
- },
- "routeKey": "GET /health",
- "stage": "$default",
- },
- "isBase64Encoded": False,
- }
-
- response = session.client("lambda").invoke(
- FunctionName=function_name,
- InvocationType="RequestResponse",
- Payload=json.dumps(event).encode(),
- )
-
- assert "FunctionError" not in response, (
- f"handler raised: {response.get('FunctionError')} -- "
- f"{response['Payload'].read().decode()[:500]}"
- )
- payload = json.loads(response["Payload"].read().decode())
- assert payload.get("statusCode") == 200, f"handler returned {payload}"
- body = payload.get("body")
- parsed = json.loads(body) if isinstance(body, str) else body
- assert parsed.get("status") == "ok", f"handler body: {parsed}"
-
-
-# --------------------------------------------------------------------------- #
-# The queue round trip
-# --------------------------------------------------------------------------- #
-
-
-def test_a_submitted_job_leaves_pending(api_url, auth_headers):
- """Stops at "no longer ``PENDING``". **A ``FAILED`` job PASSES.**
-
- This does NOT verify the inference result. Reaching ``COMPLETED`` additionally
- requires Bedrock access to ``anthropic.claude-3-haiku-20240307-v1:0``, a loaded
- passenger dataset, and an IAM scope excluding cross-region inference profiles —
- three account-state dependencies this test does not control. Phase 1's preflight
- measured that the worker model is **not invocable in this account** (the provider
- has marked it Legacy), so requiring ``COMPLETED`` would make this suite red for a
- reason that is not a defect in IPA.
-
- The terminal status and any recorded error ARE reported. The DLQ assertion is what
- catches a crash-looping worker, which this check alone would pass.
-
- Expect ``FAILED`` with ``Passenger not found: 113803`` on a namespace whose passenger
- table was never loaded — the fresh-namespace default. ``process_job`` catches every
- exception, records it on the job and returns 200, so such a failure is invisible to
- the DLQ by design; the two checks are not redundant.
- """
- # The shape is the SHIPPED contract, not a convenient placeholder: ``JobCreate``
- # (job_dto.py) requires ``input_data`` and has no ``prompt`` field, so a body this
- # test invents is rejected by FastAPI with 422 before ``submit_job`` runs -- measured
- # 2026-08-19. ``ticket`` is what the worker reads (sqs_handler.py: input_data
- # ["ticket"]); whether that passenger is loaded changes the terminal status but not
- # this test, which asserts only that the status stops being PENDING.
- submit = requests.post(
- f"{api_url}/api/v1/jobs",
- headers={**auth_headers, "Content-Type": "application/json"},
- json={"job_type": "passenger_analysis", "input_data": {"ticket": "113803"}},
- timeout=HTTP_TIMEOUT_S,
- )
- assert submit.status_code == 202, (
- f"POST /api/v1/jobs -> {submit.status_code}: {submit.text[:400]}\n"
- "A 422 means this test's body no longer matches JobCreate in "
- "app-lib/src/app_lib/features/jobs/routes/job_dto.py -- fix the body, not the "
- "DTO, unless the API contract genuinely changed."
- )
- created = submit.json()
- job_id = created.get("job_id") or created.get("id")
- assert job_id, f"submission returned no job id: {created}"
- assert created.get("status") == "PENDING", f"submitted job status: {created}"
-
- deadline = time.monotonic() + JOB_POLL_TIMEOUT_S
- terminal = None
- while time.monotonic() < deadline:
- poll = requests.get(
- f"{api_url}/api/v1/jobs/{job_id}", headers=auth_headers,
- timeout=HTTP_TIMEOUT_S,
- )
- if poll.status_code == 200:
- body = poll.json()
- if body.get("status") != "PENDING":
- terminal = body
- break
- time.sleep(JOB_POLL_INTERVAL_S)
-
- assert terminal is not None, (
- f"job {job_id} was still PENDING after {JOB_POLL_TIMEOUT_S}s; the worker is "
- "not consuming the queue"
- )
- # REPORTED, not asserted on. A FAILED job passes.
- print(
- f"\njob {job_id} reached terminal status {terminal.get('status')!r}"
- + (f" error={terminal.get('error')!r}" if terminal.get("error") else "")
- )
-
-
-def test_the_dead_letter_queue_is_empty(cfn, session, namespace):
- """A message here is the clearest sign the worker is crash-looping.
-
- ``MaxReceiveCount`` defaults to 3 (``queue.yml``), so a message in the DLQ took
- three failed receives — repeatable failure rather than one transient error. This
- is what makes the lenient job check above safe.
- """
- out = _outputs(cfn, f"{namespace}-dev-queue")
- dlq_url = out.get("DlqUrl")
- assert dlq_url, f"queue outputs carry no DlqUrl: {sorted(out)}"
-
- attrs = session.client("sqs").get_queue_attributes(
- QueueUrl=dlq_url,
- AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"],
- )["Attributes"]
- visible = int(attrs.get("ApproximateNumberOfMessages", 0))
- in_flight = int(attrs.get("ApproximateNumberOfMessagesNotVisible", 0))
- assert visible + in_flight == 0, (
- f"DLQ holds {visible} visible and {in_flight} in-flight message(s); the "
- "worker failed the same message three times"
- )
-
-
-@pytest.mark.parametrize(
- "key", ["QueueUrl", "QueueArn", "WorkerFunctionName", "DlqUrl", "JobsTableArn"]
-)
-def test_queue_stack_output_resolves(cfn, namespace, key):
- """An output that does not resolve breaks the cross-tier wiring silently."""
- out = _outputs(cfn, f"{namespace}-dev-queue")
- assert out.get(key), f"queue stack output {key} does not resolve; got {sorted(out)}"
-
-
-def test_the_event_source_mapping_is_enabled(cfn, session, namespace):
- """A disabled mapping produces a deployment where submissions succeed and are
- never delivered — from the API's side, indistinguishable from a slow worker."""
- out = _outputs(cfn, f"{namespace}-dev-queue")
- mappings = session.client("lambda").list_event_source_mappings(
- FunctionName=out["WorkerFunctionName"]
- )["EventSourceMappings"]
- assert mappings, "the worker has no event source mapping"
- mapping = mappings[0]
- assert mapping["State"] == "Enabled", f"mapping state is {mapping['State']}"
- assert mapping["BatchSize"] == 1, f"batch size is {mapping['BatchSize']}"
diff --git a/internal/integration-tests/deploy/test_teardown.py b/internal/integration-tests/deploy/test_teardown.py
deleted file mode 100644
index c6dad59..0000000
--- a/internal/integration-tests/deploy/test_teardown.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""Verify-then-remove, behind a two-part opt-in.
-
-Gated on **both** the ``teardown`` marker and ``IPA_IT_TEARDOWN=1``. One opt-in is
-satisfiable by accident: a pipeline-level variable applies to every phase of every
-build, and a marker alone is satisfied by a wildcard test selection.
-
-The verify-before-remove step is **ordering, not coverage**. Without it, a run that
-provisioned nothing and a run that provisioned everything both end with an empty
-account and a passing teardown — so the check would report success in exactly the case
-where the deployment it was meant to remove never existed.
-"""
-
-from __future__ import annotations
-
-import os
-
-import pytest
-
-from harness import aws, reaper, standing, sweep
-
-pytestmark = pytest.mark.teardown
-
-OTHER_NAMESPACE_SENTINEL = "itnotthisrun"
-
-
-@pytest.fixture(scope="module", autouse=True)
-def require_second_opt_in():
- """The environment opt-in. The marker is the first; this is the second."""
- if os.environ.get("IPA_IT_TEARDOWN") != "1":
- pytest.skip(
- "IPA_IT_TEARDOWN=1 is not set. Teardown requires two independent "
- "opt-ins, because one is satisfiable by accident."
- )
-
-
-def test_the_deployment_is_live_before_anything_is_removed(namespace, temp_project):
- """Ordering, not coverage — see the module docstring.
-
- Runs first in file order, so a namespace under which nothing was deployed fails
- here rather than passing as a successful teardown of nothing.
- """
- stacks, error = standing.live_stacks(namespace, aws.REGION)
- assert not error, f"could not verify the deployment: {error}"
- assert stacks, (
- f"nothing is deployed under {namespace!r}, so there is nothing to tear down. "
- "This fails rather than passing: a run that provisioned nothing and a run "
- "that provisioned everything otherwise both end with a passing teardown."
- )
- print(f"\nverified live before teardown: {len(stacks)} stack(s) under {namespace}")
-
-
-def test_teardown_removes_this_run(namespace, temp_project):
- """Run the reaper, which supplies the three steps IPA's skills refuse to take."""
- env_values = standing.parse_env(temp_project.root / ".env")
- report = reaper.reap(
- project=temp_project.root,
- namespace=namespace,
- env="dev",
- region=aws.REGION,
- log_bucket=env_values.get("LOG_BUCKET_NAME"),
- site_bucket=env_values.get("SITE_BUCKET_NAME") or env_values.get("APP_BUCKET_NAME"),
- ecr_repository=env_values.get("ECR_REPOSITORY_NAME"),
- )
- print("\n" + report.render())
- assert report.ok, f"teardown did not complete cleanly:\n{report.render()}"
-
-
-def test_no_stack_remains_under_this_namespace(namespace):
- stacks, error = standing.live_stacks(namespace, aws.REGION)
- assert not error, f"could not confirm removal: {error}"
- assert stacks == [], f"stacks still standing under {namespace}: {stacks}"
-
-
-def test_no_other_run_was_removed(namespace):
- """Cannot be inferred from the previous assertion.
-
- A teardown that deleted everything in the account satisfies "nothing remains under
- this namespace" perfectly, so that assertion alone cannot distinguish correct
- scoping from unbounded deletion. This checks the scoping logic directly, against a
- listing that includes another namespace.
- """
- names, error = sweep.list_it_stacks(aws.REGION)
- assert not error, f"could not list stacks: {error}"
-
- grouped = sweep.group_by_namespace(names)
- grouped.setdefault(OTHER_NAMESPACE_SENTINEL, [f"{OTHER_NAMESPACE_SENTINEL}-dev-frontend"])
-
- selected = sweep.select_namespaces(grouped, namespace)
- named = [s for stacks in selected.values() for s in stacks]
-
- assert not any(s.startswith(OTHER_NAMESPACE_SENTINEL) for s in named), (
- f"teardown scoped to {namespace!r} selected a stack from another namespace: "
- f"{named}"
- )
-
-
-def test_the_standing_resource_pointer_was_removed(namespace):
- """Removed only for a confirmed deletion (reference F5).
-
- Reached only after the removal assertions above passed, so a pointer surviving
- here would mean a confirmed teardown left a stale claim that stacks stand.
- """
- path = standing.pointer_path(namespace)
- assert not path.exists(), (
- f"{path} survived a confirmed teardown; a stale pointer teaches a reader to "
- "distrust the next one, which may be true"
- )
diff --git a/internal/integration-tests/harness/__init__.py b/internal/integration-tests/harness/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/internal/integration-tests/harness/answers.py b/internal/integration-tests/harness/answers.py
deleted file mode 100644
index 40eb89b..0000000
--- a/internal/integration-tests/harness/answers.py
+++ /dev/null
@@ -1,344 +0,0 @@
-"""Pinned answers for the questions IPA's lifecycle skills ask, and the run namespace.
-
-Two behaviours here are load-bearing, and both are about failing loudly:
-
-* **``resolve()`` RAISES on an unknown question.** ``/ipa-init`` batches its five
- questions into two ``AskUserQuestion`` calls, four then one (``SKILL.md:111``).
- Answering three and guessing the fourth writes an ``.env`` that is wrong in one
- field and still passes the init gate — so the run continues and fails later,
- somewhere that does not mention the field. Raising converts that into an
- immediate, named failure.
-
- The four-then-one split is the tool's limit, not a preference, and it is why
- this table is keyed per question rather than per batch: ``resolve()`` is called
- once per call, and neither call is "the init batch". Until 2026-08-18 the skill
- asked all five at once with a one-option namespace question, which
- ``AskUserQuestion`` rejects outright — so the hook never fired, this whole table
- went unused, and the agent wrote ``.env`` from the appended system prompt
- instead. Nothing in the compose scenario's assertions distinguished that from a
- working answer path except ``test_the_answer_channel_is_recorded``.
-* **``make_namespace()`` asserts at the point of generation.** An invalid
- namespace is otherwise accepted by everything until a deployment rejects it,
- many minutes later, with a Cognito domain-prefix error that never mentions the
- namespace.
-
-Question text is the primary key so that a prose edit to a skill surfaces as a
-failure rather than as a silently different answer. The option-label-set fallback
-exists for a reworded question whose options are unchanged.
-"""
-
-from __future__ import annotations
-
-import os
-import re
-import secrets
-from dataclasses import dataclass, field
-
-from harness import aws
-
-# --------------------------------------------------------------------------- #
-# Namespace rules — THE single definition. Nothing else may restate these.
-# --------------------------------------------------------------------------- #
-
-#: From /ipa-init's own validation table (SKILL.md:85): 1-12 chars, lowercase
-#: letters/digits/hyphens, must start with a letter.
-NS_PATTERN = r"^[a-z][a-z0-9-]{0,11}$"
-NS_RE = re.compile(NS_PATTERN)
-
-#: Cognito rejects these substrings in a hosted-UI domain prefix. Recorded in
-#: infra/cfn/cognito/cognito.yml's own comment on the CognitoDomainPrefix
-#: parameter, and the namespace flows into that prefix.
-NS_FORBIDDEN = ("cognito", "aws", "amazon")
-
-NS_MAX_LEN = 12
-
-
-def make_namespace(injected: str | None = None) -> str:
- """Return a validated run namespace.
-
- Validation happens INSIDE this function so an injected namespace and a
- generated one pass through the same gate. That is what Phase 5's
- ``IPA_IT_NAMESPACE`` seam depends on: a caller writing
- ``namespace or make_namespace()`` would otherwise never check the injected
- value at all.
-
- Raises ``ValueError`` naming the specific rule violated, never a generic
- message.
-
- ``None`` means "generate one". An **empty string is invalid, not absent** —
- otherwise ``make_namespace("")`` would silently generate a namespace, and a
- caller that meant to inject one would get a different namespace than it
- recorded. Callers reading an environment variable must therefore pass
- ``os.environ.get(...) or None``.
- """
- ns = "it" + secrets.token_hex(5) if injected is None else injected
-
- if not ns:
- raise ValueError("namespace is empty")
- if len(ns) > NS_MAX_LEN:
- raise ValueError(
- f"namespace {ns!r} is {len(ns)} chars; max is {NS_MAX_LEN}"
- )
- if ns.startswith("-"):
- raise ValueError(f"namespace {ns!r} must not start with a hyphen")
- if ns.endswith("-"):
- raise ValueError(f"namespace {ns!r} must not end with a hyphen")
- if "--" in ns:
- raise ValueError(f"namespace {ns!r} must not contain consecutive hyphens")
- if not NS_RE.match(ns):
- raise ValueError(f"namespace {ns!r} violates {NS_PATTERN}")
- for bad in NS_FORBIDDEN:
- if bad in ns:
- raise ValueError(
- f"namespace {ns!r} contains {bad!r}, which Cognito forbids in a "
- "hosted-UI domain prefix"
- )
- return ns
-
-
-# --------------------------------------------------------------------------- #
-# The answer table
-# --------------------------------------------------------------------------- #
-
-#: HITL default K1.A. Matches Phase 1's probe, and satisfies /ipa-deploy Step 1.5
-#: identically to PowerUserAccess: that gate checks stack status only and never
-#: assumes either role.
-MANAGED_POLICY_ANSWER = "ReadOnlyAccess"
-
-#: Sentinel marking an answer whose value is computed per run rather than fixed.
-_NAMESPACE = ""
-_PROFILE = ""
-
-
-@dataclass(frozen=True)
-class Answer:
- """One pinned answer.
-
- ``free_text`` records that the value is typed into the question's built-in
- "Other" path rather than selected from an offered option. It exists so the
- resolver never returns the literal placeholder label: returning ``"Other"``
- writes the word "Other" into ``.env``, and it is accepted.
- """
-
- value: str
- free_text: bool = False
- options: tuple[str, ...] = field(default=())
- note: str = ""
-
-
-#: Nine entries, keyed by EXACT question text as the skills state it.
-#:
-#: The five ``/ipa-init`` entries arrive across TWO calls (four questions, then one),
-#: so the resolver sees two batches. Nothing here depends on which batch a question
-#: came in — deliberately, because the split is the tool's 4-per-call limit and
-#: regrouping the questions must not require touching this table.
-#:
-#: Questions 6 and 7 are held separately on purpose. /ipa-compose asks "How should
-#: IPA configure security infrastructure?" offering "Managed Policy ARN";
-#: /ipa-security asks "How should IPA configure IAM execution roles?" offering
-#: "Managed policy". They occupy the same role and differ in wording, so a future
-#: prose edit aligning them must not collapse them into one entry — one skill
-#: would then silently receive the other's answer.
-ANSWERS: dict[str, Answer] = {
- # --- /ipa-init, five questions across TWO calls: 1-4, then 5 (SKILL.md:111) -
- "Which AWS CLI profile should IPA use?": Answer(
- value=_PROFILE,
- free_text=True,
- options=("Skip", "default"),
- note=(
- "Answered with a REAL profile name via the built-in Other path. "
- "'Skip' (the recommended option) omits AWS_PROFILE from .env "
- "entirely, and three downstream skills require it — so accepting the "
- "recommendation here would fail later."
- ),
- ),
- "Which AWS region for deployments?": Answer(
- value="us-east-1",
- options=("us-east-1", "us-west-2", "eu-west-1"),
- note=(
- "Itself the recommended option, so agreement on it cannot prove the "
- "harness answered. The namespace is the only discriminator."
- ),
- ),
- (
- "Choose a project namespace. All CloudFormation stacks start with "
- "`{namespace}-{env}-`. Must be unique per account+environment. "
- "(1-12 chars, lowercase letters/digits/hyphens, starts with letter)"
- ): Answer(
- value=_NAMESPACE,
- free_text=True,
- options=("app", "sandbox"),
- note=(
- "The ONLY discriminator between 'the harness answered' and 'the agent "
- "used skill defaults'. Neither offered option is ever the answer -- the "
- "run namespace goes through the built-in Other path -- and the driver "
- "ALSO states it in the appended system prompt, because a single channel "
- "for the one value that proves interception is thin. "
- "'sandbox' is the second option because a one-option question is "
- "REJECTED by AskUserQuestion, which is how this question silently "
- "disabled the whole answer path until 2026-08-18."
- ),
- ),
- "Which environment?": Answer(
- value="dev",
- options=("dev", "stage", "prod"),
- ),
- "Which infrastructure-as-code tool?": Answer(
- value="cloudformation",
- options=("cloudformation", "terraform"),
- note=(
- "Pinned. An unbounded Terraform provider range with no committed "
- "lockfile makes a green run and a red run differ by an upstream "
- "release."
- ),
- ),
- # --- /ipa-compose Step 1.5 (SKILL.md:122) ---------------------------------
- "How should IPA configure security infrastructure?": Answer(
- value="Managed Policy ARN",
- options=(
- "Existing Role ARN",
- "Managed Policy ARN",
- "Innovation Builder Stack",
- ),
- note=(
- "Route A. Delegates to /ipa-security so a REAL {ns}-{env}-security "
- "stack exists — which /ipa-deploy Step 1.5 requires and which "
- "/ipa-compose Step 1.5 does not."
- ),
- ),
- # --- /ipa-security (SKILL.md:107) -----------------------------------------
- "How should IPA configure IAM execution roles?": Answer(
- value="Managed policy",
- options=(
- "Innovation Builder Stack",
- "Managed policy",
- "Existing role ARNs",
- ),
- note="Distinct entry from /ipa-compose's question. Do not merge.",
- ),
- "Which managed policy should IPA attach to both execution roles?": Answer(
- value=MANAGED_POLICY_ANSWER,
- options=("PowerUserAccess", "ReadOnlyAccess"),
- note="HITL default K1.A: ReadOnlyAccess over the recommended PowerUserAccess.",
- ),
- "Deploy this security configuration?": Answer(
- value="Yes, deploy",
- options=("Yes, deploy", "No, start over"),
- ),
-}
-
-assert len(ANSWERS) == 9, f"expected nine pinned answers, found {len(ANSWERS)}"
-
-
-class UnknownQuestion(RuntimeError):
- """Raised when the resolver is asked something it has no pinned answer for.
-
- Deliberately fatal. A partial map is the failure mode this whole module is
- built to prevent.
- """
-
-
-class AnswerResolver:
- """Resolves a batch of questions into pinned answers, or raises."""
-
- def __init__(self, namespace: str, profile: str) -> None:
- # Passed THROUGH make_namespace, not around it, so an injected value is
- # validated by the same gate a generated one is.
- self.namespace = make_namespace(namespace)
- self.profile = profile
-
- # -- lookup ------------------------------------------------------------- #
-
- def _by_text(self, text: str) -> Answer | None:
- # Exact text first, then whitespace-normalised, so a rewrapped prompt
- # still matches its own entry.
- if text in ANSWERS:
- return ANSWERS[text]
- norm = " ".join(text.split())
- for question, answer in ANSWERS.items():
- if " ".join(question.split()) == norm:
- return answer
- return None
-
- def _by_options(self, labels: list[str]) -> Answer | None:
- """Fallback: match on the SET of offered option labels.
-
- Consulted only when exact-text lookup misses, so a reworded question with
- unchanged options still resolves. Labels are compared with any
- "(Recommended)" suffix stripped, because that decoration is presentational.
- """
- offered = {self._strip_recommended(label) for label in labels}
- if not offered:
- return None
- for answer in ANSWERS.values():
- if answer.options and offered == set(answer.options):
- return answer
- return None
-
- @staticmethod
- def _strip_recommended(label: str) -> str:
- return label.replace("(Recommended)", "").strip()
-
- def _materialise(self, answer: Answer) -> str:
- """Substitute the per-run sentinels.
-
- A free-text answer's value is the TEXT ITSELF, never the literal
- placeholder label the tool uses to offer the free-text path.
- """
- if answer.value == _NAMESPACE:
- return self.namespace
- if answer.value == _PROFILE:
- return self.profile
- return answer.value
-
- # -- public API --------------------------------------------------------- #
-
- def resolve_one(self, text: str, labels: list[str] | None = None) -> str:
- answer = self._by_text(text)
- if answer is None:
- answer = self._by_options(labels or [])
- if answer is None:
- raise UnknownQuestion(
- f"no pinned answer for question {text!r} "
- f"(offered options: {labels!r}). Refusing to guess: answering some "
- "questions in a batch and guessing one writes a subtly wrong .env "
- "that still passes the init gate."
- )
- return self._materialise(answer)
-
- def resolve(self, questions: list[dict]) -> dict[str, str]:
- """Resolve a whole batch, or raise if ANY question is unknown.
-
- Raises before returning anything, so a batch containing one unknown
- question yields no partial map.
- """
- resolved: dict[str, str] = {}
- for q in questions:
- text = q.get("question") or q.get("text") or ""
- labels = [
- o.get("label", "") if isinstance(o, dict) else str(o)
- for o in (q.get("options") or [])
- ]
- resolved[text] = self.resolve_one(text, labels)
- return resolved
-
-
-def resolver_from_env(profile: str | None = None) -> AnswerResolver:
- """Build a resolver, honouring the ``IPA_IT_NAMESPACE`` seam.
-
- The injected namespace is passed through ``make_namespace`` inside
- ``AnswerResolver.__init__``, which is what validates it.
-
- The profile comes from ``AWS_PROFILE`` alone and RAISES when unset. There is
- deliberately no ``"default"`` fallback: that value is accepted by ``/ipa-init``,
- written into ``.env``, and then fails much later inside a generated Makefile with
- an error naming a missing profile rather than the configuration step that should
- have supplied one. The resolver still needs a real profile STRING because the
- answer table has a profile question to answer -- what changed is that nothing
- authenticates with it.
- """
- injected = os.environ.get("IPA_IT_NAMESPACE") or None
- resolved_profile = profile or aws.profile_for_env()
- return AnswerResolver(
- namespace=injected or make_namespace(), profile=resolved_profile
- )
diff --git a/internal/integration-tests/harness/artifacts.py b/internal/integration-tests/harness/artifacts.py
deleted file mode 100644
index 065f593..0000000
--- a/internal/integration-tests/harness/artifacts.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""Parsers for the ``scripts/*.mk`` artifacts ``/ipa-compose`` generates.
-
-Kept as its own module rather than folded into ``test_compose.py``, which has the
-only consumer. Separation is what makes the composition assertions testable
-offline against fixture Makefiles — which is the whole point of the offline tier.
-Folding it in would save one file and lose one test tier.
-
-Every function here reads text and returns data. None of them asserts, so the same
-parser serves an offline fixture test and a live composition assertion.
-"""
-
-from __future__ import annotations
-
-import re
-from pathlib import Path
-
-#: The eight artifacts /ipa-compose reports generating (ipa-compose/SKILL.md:266-274).
-#: The claim that all eight appear on a real run had never been observed before
-#: this harness; asserting presence is what turns it into a verified fact.
-EXPECTED_ARTIFACTS = (
- "scripts/prepare.mk",
- "scripts/deploy.mk",
- "scripts/build.mk",
- "scripts/post-deploy.mk",
- "scripts/env.mk",
- "scripts/test.mk",
- "scripts/release.mk",
- "scripts/SECURITY-DISPOSITION.md",
-)
-
-
-def missing_artifacts(project: Path) -> list[str]:
- """Which of the eight expected artifacts are absent."""
- return [rel for rel in EXPECTED_ARTIFACTS if not (project / rel).exists()]
-
-
-def read(project: Path, rel: str) -> str:
- return (project / rel).read_text()
-
-
-# --------------------------------------------------------------------------- #
-# Targets and prerequisites
-# --------------------------------------------------------------------------- #
-
-#: A target line: name, colon, optional prerequisites. Excludes `::`, pattern
-#: rules, and variable assignments (`FOO := bar` has no colon-space form that
-#: matches, but `FOO:=bar` would, so `=` is excluded explicitly).
-_TARGET_RE = re.compile(r"^([A-Za-z0-9_.\-/]+)\s*:(?!=)\s*(.*)$")
-
-
-def targets(text: str) -> list[str]:
- """Every target name declared in a Makefile, in declaration order."""
- found: list[str] = []
- for line in text.splitlines():
- if line.startswith("\t"):
- continue
- m = _TARGET_RE.match(line)
- if m and m.group(1) != ".PHONY":
- found.append(m.group(1))
- return found
-
-
-def target_count(text: str, name: str) -> int:
- """How many times ``name`` is declared as a target.
-
- A duplicated container-build target either builds twice or builds the wrong
- one depending on ordering, so the count matters and not merely presence.
- """
- return sum(1 for t in targets(text) if t == name)
-
-
-def prerequisites(text: str, name: str) -> list[str]:
- """The prerequisites of target ``name``, in order.
-
- Returns an empty list when the target is absent, so a caller distinguishes
- "no prerequisites" from "no target" by checking ``name in targets(text)``.
- """
- for line in text.splitlines():
- if line.startswith("\t"):
- continue
- m = _TARGET_RE.match(line)
- if m and m.group(1) == name:
- return m.group(2).split()
- return []
-
-
-def target_body(text: str, name: str) -> str:
- """The recipe lines of target ``name``, joined.
-
- Recipe lines are the tab-indented lines following the target line, up to the
- next non-indented non-blank line.
- """
- lines = text.splitlines()
- body: list[str] = []
- capturing = False
- for line in lines:
- if not line.startswith("\t"):
- m = _TARGET_RE.match(line)
- if m and m.group(1) == name:
- capturing = True
- continue
- if capturing and line.strip():
- break
- continue
- if capturing:
- body.append(line)
- return "\n".join(body)
-
-
-def _suffixes(prereqs: list[str], prefix: str) -> list[str]:
- """Strip a ``deploy-``/``teardown-`` prefix, leaving the tier suffixes."""
- return [p[len(prefix) :] if p.startswith(prefix) else p for p in prereqs]
-
-
-def teardown_is_reverse_of_deploy(text: str) -> tuple[bool, list[str], list[str]]:
- """Whether ``teardown:``'s prerequisites are the exact reverse of ``deploy:``'s.
-
- Compares TIER SUFFIXES, not raw target names. ``MAKEFILE_TEMPLATES.md:54``
- generates ``deploy: deploy-{suffix1} ... deploy-{suffixN}`` and ``:162``
- generates ``teardown: teardown-{suffixN} ... teardown-{suffix1}`` — so the two
- prerequisite lists never share a name, and comparing them raw reports every
- healthy artifact as misordered.
-
- Teardown in any order other than the exact reverse fails on a resource another
- still depends on, and the failure leaves a partially removed deployment — more
- expensive to reason about than either a complete one or none.
-
- Returns ``(ok, deploy_prereqs, teardown_prereqs)`` so a failing assertion can
- show both lists rather than only a boolean.
- """
- dep = prerequisites(text, "deploy")
- tear = prerequisites(text, "teardown")
- ok = bool(dep) and bool(tear) and _suffixes(tear, "teardown-") == list(
- reversed(_suffixes(dep, "deploy-"))
- )
- return ok, dep, tear
-
-
-# --------------------------------------------------------------------------- #
-# Parameter overrides
-# --------------------------------------------------------------------------- #
-
-_PARAM_RE = re.compile(r"([A-Za-z0-9_]+)=([^\s\\]+)")
-
-
-def parameter_overrides(text: str, target: str) -> dict[str, str]:
- """The ``Key=Value`` pairs a target passes as CloudFormation parameter overrides.
-
- Used for the one artifact-level proof that compose's cross-tier auto-wiring
- fired: ``deploy-backend`` must carry ``EnableSqsIntegration=true``. Without it
- the tiers deploy independently and the failure appears only at runtime, as a
- job accepted and never processed.
- """
- body = target_body(text, target)
- if "--parameter-overrides" not in body:
- return {}
- after = body.split("--parameter-overrides", 1)[1]
- return dict(_PARAM_RE.findall(after))
-
-
-# --------------------------------------------------------------------------- #
-# The deploy.mk header tier set
-# --------------------------------------------------------------------------- #
-
-_HEADER_TIER_RE = re.compile(r"^#.*\b(?:stacks?|tiers?)\b\s*:\s*(.+)$", re.IGNORECASE)
-
-
-def header_tiers(text: str) -> set[str]:
- """The tier set recorded in a generated Makefile's header comment.
-
- ``/ipa-compose`` Step 0.3a reads this back on an idempotent refresh, so it is
- the artifact's own record of what was composed. A composition that silently
- omits a tier generates artifacts that succeed at everything they attempt while
- deploying a different architecture than the one asked for.
-
- Falls back to deriving the set from ``deploy-*`` target names when no header
- line is present, so a header-format change degrades to a weaker check rather
- than to a false pass.
- """
- for line in text.splitlines():
- if not line.startswith("#"):
- break
- m = _HEADER_TIER_RE.match(line)
- if m:
- raw = m.group(1).replace(",", " ")
- return {t.strip() for t in raw.split() if t.strip()}
- return {
- t[len("deploy-") :]
- for t in targets(text)
- if t.startswith("deploy-") and t != "deploy-all"
- }
diff --git a/internal/integration-tests/harness/aws.py b/internal/integration-tests/harness/aws.py
deleted file mode 100644
index 5ed832f..0000000
--- a/internal/integration-tests/harness/aws.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""How the harness reaches AWS. ONE definition, so no call site can disagree.
-
-Two questions live here that look like one, and conflating them is what made the
-previous design look unavoidable:
-
-1. **How does the harness authenticate?** From botocore's default credential chain,
- naming no profile. See ``session``.
-2. **What profile name does the driven project need in its own ``.env``?** A real
- name, supplied by ``profile_for_env``, and never used to build a session.
-
-Keeping them separate is what lets the harness run unchanged on a workstation and in a
-CI container while still satisfying four shipped skills that stop when ``AWS_PROFILE``
-is missing from a project's configuration.
-"""
-
-from __future__ import annotations
-
-import os
-
-import boto3
-
-# Read ONCE at import, not per call. A call-time read would let two calls in one run
-# target different regions, which is precisely the disagreement this module exists to
-# make impossible.
-REGION = os.environ.get("IPA_IT_REGION", "us-east-1")
-
-
-def session(region: str = REGION) -> boto3.Session:
- """Region PINNED, credentials from the DEFAULT CHAIN.
-
- The region is explicit because this shell exports ``AWS_REGION=us-west-2``, and an
- inherited region would probe an account that is clean only because nothing was ever
- there -- a false pass that is indistinguishable from a correct one.
-
- The credentials are ambient ON PURPOSE. The chain reads the environment or an SSO
- cache on a workstation and the ECS container role inside CodeBuild, so one code
- path serves both and no maintainer's profile name appears in source.
- """
- return boto3.Session(region_name=region)
-
-
-def cli_region_args(region: str = REGION) -> list[str]:
- """The region flags for an ``aws`` CLI argv. NEVER a ``--profile`` flag.
-
- Kept despite wrapping two list elements, because it is the grep-able marker that an
- argv site was reviewed during the profile removal. An inline ``["--region", REGION]``
- is indistinguishable from a site that was missed.
- """
- return ["--region", region]
-
-
-def profile_for_env() -> str:
- """The value the AGENT writes to ``.env``'s ``AWS_PROFILE``. Never builds a session.
-
- Separate from ``session`` because the two answer different questions. Four shipped
- skills -- ``/ipa-compose``, ``/ipa-prepare``, ``/ipa-security`` and ``/ipa-destroy``
- -- STOP when ``AWS_PROFILE`` is absent from ``.env``, so a driven run needs a name
- there. Nothing in this harness needs one to make an AWS call.
-
- Raises rather than defaulting to ``"default"``: ``AWS_PROFILE=default`` in ``.env``
- is accepted by ``/ipa-init`` and then fails much later inside a generated Makefile,
- with an error naming a missing profile rather than the configuration step that
- should have supplied one.
- """
- value = os.environ.get("AWS_PROFILE", "").strip()
- if not value:
- raise RuntimeError(
- "AWS_PROFILE is not set. The harness's own AWS calls do not need it, but "
- "/ipa-init writes it to .env and /ipa-compose, /ipa-prepare, /ipa-security "
- "and /ipa-destroy all stop without it. Locally: export AWS_PROFILE=. In CodeBuild it is set to 'ipa-it', a credential-less profile "
- "the install phase creates so the CLI falls through to container "
- "credentials."
- )
- return value
diff --git a/internal/integration-tests/harness/cognito_auth.py b/internal/integration-tests/harness/cognito_auth.py
deleted file mode 100644
index 7bd41ea..0000000
--- a/internal/integration-tests/harness/cognito_auth.py
+++ /dev/null
@@ -1,136 +0,0 @@
-"""Mint a real Cognito identity token from the run's own user pool.
-
-The **ID token** is returned, not the access token: only the ID token carries
-``aud = client_id``, which makes it unambiguous under both auth layers — API
-Gateway's JWT authorizer and ``app-lib``'s own middleware. Phase 1's preflight probe
-already verified that this claim matches the deployed client id, so this module is
-continuous with the probe rather than new.
-
-**The password is the one secret this harness generates.** It is produced with
-``secrets``, built by character class rather than by rejection sampling, and held in
-memory only. Callers must register it with the transcript's redaction filter — see
-``mint_token``'s return value.
-"""
-
-from __future__ import annotations
-
-import base64
-import json
-import secrets
-import string
-from dataclasses import dataclass
-
-from harness import aws
-
-
-@dataclass(frozen=True)
-class Identity:
- """A throwaway user and its identity token.
-
- ``password`` is returned so the caller can register it for redaction. It is not
- written to any file by this module.
- """
-
- username: str
- password: str
- id_token: str
- client_id: str
-
-
-def generate_password(length: int = 20) -> str:
- """A password satisfying all four required character classes, by construction.
-
- ``cognito.yml``'s ``Policies.PasswordPolicy`` requires lowercase, uppercase,
- numbers and symbols — all four. Built by class rather than by rejection sampling,
- so it cannot fail intermittently against that policy.
- """
- symbols = "!@#$%^&*()-_=+"
- pools = [string.ascii_lowercase, string.ascii_uppercase, string.digits, symbols]
- chars = [secrets.choice(p) for p in pools]
- alphabet = "".join(pools)
- chars += [secrets.choice(alphabet) for _ in range(length - len(pools))]
- for i in range(len(chars) - 1, 0, -1):
- j = secrets.randbelow(i + 1)
- chars[i], chars[j] = chars[j], chars[i]
- return "".join(chars)
-
-
-def decode_payload(token: str) -> dict:
- """Decode a JWT payload WITHOUT verifying its signature.
-
- Narrowly deliberate: the token was minted seconds earlier by this module against
- the pool it names, and the only claim read is compared to a value from that same
- deployment. Never a pattern for anything that trusts an inbound token.
- """
- segment = token.split(".")[1]
- segment += "=" * (-len(segment) % 4)
- return json.loads(base64.urlsafe_b64decode(segment))
-
-
-def mint_token(
- user_pool_id: str,
- client_id: str,
- region: str = aws.REGION,
-) -> Identity:
- """Create a throwaway confirmed user and authenticate it.
-
- **This is the only write the live assertions make.** The user lives inside the
- run's own user pool, so the pool's own deletion removes it with no separate
- cleanup obligation.
- """
- idp = aws.session(region).client("cognito-idp")
-
- username = f"ipa-it-{secrets.token_hex(4)}@example.invalid"
- password = generate_password()
-
- idp.admin_create_user(
- UserPoolId=user_pool_id,
- Username=username,
- # No mail to a non-existent address.
- MessageAction="SUPPRESS",
- UserAttributes=[
- {"Name": "email", "Value": username},
- {"Name": "email_verified", "Value": "true"},
- ],
- )
- # Permanent=True clears FORCE_CHANGE_PASSWORD, which would otherwise answer
- # InitiateAuth with a NEW_PASSWORD_REQUIRED challenge rather than tokens.
- idp.admin_set_user_password(
- UserPoolId=user_pool_id,
- Username=username,
- Password=password,
- Permanent=True,
- )
-
- auth = idp.initiate_auth(
- ClientId=client_id,
- AuthFlow="USER_PASSWORD_AUTH",
- AuthParameters={"USERNAME": username, "PASSWORD": password},
- )
-
- if "ChallengeName" in auth:
- raise AssertionError(
- f"adaptive authentication challenged InitiateAuth: "
- f"ChallengeName={auth['ChallengeName']!r} "
- f"ChallengeParameters={auth.get('ChallengeParameters')!r}. "
- "There is no fallback: ALLOW_ADMIN_USER_PASSWORD_AUTH is absent from the "
- "pool client's ExplicitAuthFlows."
- )
-
- id_token = auth.get("AuthenticationResult", {}).get("IdToken")
- if not id_token:
- raise AssertionError(
- f"no IdToken in AuthenticationResult: "
- f"{list(auth.get('AuthenticationResult', {}))}"
- )
-
- aud = decode_payload(id_token).get("aud")
- if aud != client_id:
- raise AssertionError(
- f"IdToken aud={aud!r} != UserPoolClientId={client_id!r}; the API "
- "Gateway authorizer will reject it"
- )
-
- return Identity(
- username=username, password=password, id_token=id_token, client_id=client_id
- )
diff --git a/internal/integration-tests/harness/driver.py b/internal/integration-tests/harness/driver.py
deleted file mode 100644
index 71493a4..0000000
--- a/internal/integration-tests/harness/driver.py
+++ /dev/null
@@ -1,606 +0,0 @@
-"""Drive the IPA lifecycle skills unattended through one Claude Agent SDK session.
-
-Five traps are designed out here rather than debugged later. Each one produced a
-failure whose symptom names nothing relevant:
-
-1. **``env`` REPLACES the child environment**, so it is merged over ``os.environ``.
- A bare three-key dict strands the child without ``PATH`` and without the AWS
- credential chain, and the handshake dies about 66 seconds later with
- ``Control request timeout: initialize`` — which names neither.
-2. **The region is PINNED, not inherited.** This shell exports
- ``AWS_REGION=us-west-2``. The generated Makefiles pass ``--region`` explicitly at
- 28 sites and so follow ``.env``, but ``/ipa-deploy`` Step 1.5's
- ``describe-stacks`` is a bare ``aws`` call with no ``--region``
- (``ipa-deploy/SKILL.md:127``). Left unpinned, that gate looks in ``us-west-2``,
- finds nothing, and hard-stops with *"Security stack is not deployed"* after Route
- A demonstrably deployed it in ``us-east-1``.
-3. **ONE long-lived client for the whole session.** A client per lifecycle stage
- closes its input stream when the stage's prompt is exhausted, after which the
- answer callback can no longer fire — producing a run that stops at the first
- question with no error, no output, and no sign that it is waiting.
-4. **The ``PreToolUse`` hook is what answers the questions**, not ``can_use_tool``.
- Measured (reference ``iac_compose/FINDINGS.md`` F2), contradicting both the SDK
- documentation and this repository's own 2026-07-31 research. ``can_use_tool`` is
- registered anyway as a **tripwire**: without it, an SDK that switches channels
- would silently fall through to skill defaults (``APP_NAMESPACE=app``) while
- reporting success. Both paths funnel into the same resolver so they cannot
- disagree.
-5. **``setting_sources=["project"]``** is required for the temp project's
- ``.claude/skills/ipa-*`` to load at all.
-6. **A finished turn is not a finished lifecycle.** ``ResultMessage(subtype='success',
- stop_reason='end_turn')`` is what the SDK reports for a drive that stopped a tenth
- of the way through ``/ipa-compose`` — measured twice at the same boundary, where
- compose delegates to ``/ipa-security``. Nothing in the result distinguishes that
- from completion, so ``drive`` takes an ``is_complete`` predicate supplied by the
- scenario, checks it against disk and AWS, and nudges a bounded number of times.
- Without it the truncation surfaces twenty-one ``describe_stacks`` failures later,
- naming a missing stack rather than the turn that ended.
-
-``permission_mode="acceptEdits"`` is the only mode where the answer path is both
-reachable and un-shadowed: ``dontAsk`` denies ``AskUserQuestion`` without invoking
-anything, and ``bypassPermissions`` raises ``CanUseToolShadowedWarning``.
-"""
-
-from __future__ import annotations
-
-import json
-import os
-import time
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-
-from harness.answers import AnswerResolver
-
-# The SDK is an OPTIONAL extra (`uv sync --all-extras`), so it is imported lazily.
-#
-# A module-level import here would make `import driver` fail on a machine without the
-# extra -- and `conftest.py` imports this module, so pytest could not even COLLECT the
-# offline tests. That would defeat the whole point of putting the SDK behind an extra:
-# the offline tier is meant to run anywhere, on any checkout, with no credentials and
-# no agent. Verified by running the suite on a fresh checkout, where it failed.
-#
-# Everything in this module that the offline tier touches -- Transcript, the redaction
-# filter, scan_for_gates, BILLABLE_GATE_MARKERS, the prompts -- is pure Python and
-# stays importable. Only building a session needs the SDK, and that is the billable
-# path, which `conftest.py` already gates on the `claude_agent_sdk_importable` probe.
-try: # pragma: no cover - exercised by presence/absence of the extra
- from claude_agent_sdk import (
- ClaudeAgentOptions,
- ClaudeSDKClient,
- HookMatcher,
- PermissionResultAllow,
- )
-
- SDK_AVAILABLE = True
- SDK_IMPORT_ERROR = ""
-except ImportError as _exc: # pragma: no cover
- ClaudeAgentOptions = ClaudeSDKClient = HookMatcher = PermissionResultAllow = None # type: ignore[assignment,misc]
- SDK_AVAILABLE = False
- SDK_IMPORT_ERROR = str(_exc)
-
-
-def _require_sdk() -> None:
- """Fail with an actionable message rather than a NameError."""
- if not SDK_AVAILABLE:
- raise RuntimeError(
- f"claude_agent_sdk is not installed ({SDK_IMPORT_ERROR}). It is an "
- "optional extra so the offline tier runs without it. Install with: "
- "uv sync --all-extras"
- )
-
-REGION = os.environ.get("IPA_IT_REGION", "us-east-1")
-MODEL = os.environ.get(
- "IPA_IT_DRIVER_MODEL", "us.anthropic.claude-sonnet-4-20250514-v1:0"
-)
-
-#: Phase 3 ceiling (HITL default Q2.A). The reference measured its compose-only path
-#: at $1.61-$2.41 over 8-13 minutes; IPA's compose reads more stack skills and adds
-#: /ipa-security, so 5 leaves headroom without letting a wedged run spend Phase 4's
-#: budget. Phase 4 raises both -- see ``full_lifecycle_options``.
-MAX_BUDGET_USD = float(os.environ.get("IPA_IT_MAX_BUDGET_USD", "5"))
-MAX_TURNS = int(os.environ.get("IPA_IT_MAX_TURNS", "300"))
-
-#: The FULL-LIFECYCLE ceiling, and a SEPARATE variable from ``IPA_IT_MAX_BUDGET_USD`` on
-#: purpose. The two scopes differ roughly threefold, and each scenario sets its own
-#: ceiling inline -- so one shared variable set for the compose scenario would cap a
-#: deploy run at $5 and stop it partway through a deployment. A drive stopped by a
-#: ceiling is indistinguishable from a wedge and leaves partial infrastructure standing,
-#: which is the cheap scenario's configuration causing the expensive scenario's worst
-#: failure.
-#:
-#: Previously a literal ``15.0`` inside ``full_lifecycle_options``, which meant the
-#: documented spend ceiling could not reach the only path able to exhaust it.
-FULL_MAX_BUDGET_USD = float(os.environ.get("IPA_IT_FULL_MAX_BUDGET_USD", "15"))
-
-#: How many times a drive may ask the agent to carry on after it ended its turn with
-#: the lifecycle unfinished. See ``drive``'s ``is_complete``.
-#:
-#: TWO, not more. A nudge is a backstop for a skill that stopped early, and each one
-#: re-enters a session that has already demonstrated it thinks it is done. Raising this
-#: converts "the skill stops early" into "the harness pays repeatedly to push it
-#: through", which hides the defect the counter exists to report. Set to ``0`` to
-#: measure the unnudged behaviour of the skills as authored.
-MAX_NUDGES = int(os.environ.get("IPA_IT_MAX_NUDGES", "2"))
-
-#: IPA's own prose gates, verified in source. The reference's markers were its own
-#: and do not transfer.
-#:
-#: Matched case-insensitively, and the FULL SURROUNDING TEXT is recorded verbatim.
-#: What the agent *said* while crossing a billable boundary is the finding -- whether
-#: it asked, assumed, warned, or simply proceeded. A boolean "did it proceed"
-#: discards the entire content of the finding.
-BILLABLE_GATE_MARKERS = {
- # /ipa-prepare SKILL.md:136
- "prepare:proceed": "proceed? (yes/no)",
- # /ipa-deploy SKILL.md:189
- "deploy:proceed": "proceed with deployment? (yes/no)",
- # /ipa-compose SKILL.md:86 -- re-composition overwrite warning
- "compose:overwrite": "proceed? (yes to overwrite, no to cancel)",
- # /ipa-deploy SKILL.md:226 -- build failure must STOP, not continue
- "deploy:build_failed": "do not proceed to deploy",
- # /ipa-security Step 5
- "security:deploy": "deploy this security configuration?",
-}
-
-
-# --------------------------------------------------------------------------- #
-# Transcript
-# --------------------------------------------------------------------------- #
-
-
-@dataclass
-class Transcript:
- """Append-only JSONL record, written and flushed PER EVENT.
-
- Never assembled in memory: on the billable path a hard crash can leave
- infrastructure standing and costing money, and the transcript is then the only
- record of what was created. A record that lives in the crashed process's memory
- is no record at all.
- """
-
- path: Path
- redactions: list[str] = field(default_factory=list)
-
- def redact(self, text: str) -> str:
- """Replace every registered secret with a placeholder.
-
- Keyed on the LIVE VALUE at the writer rather than applied by care at each
- write site -- care at each site is what a later-added sink escapes.
- """
- for secret in self.redactions:
- if secret:
- text = text.replace(secret, "***REDACTED***")
- return text
-
- def register_secret(self, secret: str) -> None:
- if secret and secret not in self.redactions:
- self.redactions.append(secret)
-
- def record(self, kind: str, **payload: Any) -> None:
- event = {
- "kind": kind,
- "at": datetime.now(timezone.utc).isoformat(),
- **payload,
- }
- line = self.redact(json.dumps(event, default=str))
- with self.path.open("a") as fh:
- fh.write(line + "\n")
- fh.flush()
-
- def record_source(self, source: Any) -> None:
- """First entry: WHAT TREE the run ran against.
-
- A result that names a passing run but not the tree it ran against is not a
- result, and for a dirty tree the commit alone is a false description.
- """
- self.record(
- "source",
- commit=getattr(source, "commit", None),
- dirty=getattr(source, "dirty", None),
- file_count=getattr(source, "file_count", None),
- root=str(getattr(source, "root", "")),
- )
-
-
-def scan_for_gates(transcript: Transcript, text: str) -> list[str]:
- """Record every billable-gate marker present in assistant text."""
- lowered = text.lower()
- hit: list[str] = []
- for gate, marker in BILLABLE_GATE_MARKERS.items():
- if marker in lowered:
- # FULL surrounding text, verbatim.
- transcript.record("billable_gate", gate=gate, marker=marker, text=text)
- hit.append(gate)
- return hit
-
-
-# --------------------------------------------------------------------------- #
-# The appended system prompt
-# --------------------------------------------------------------------------- #
-
-#: The bound on how far the agent proceeds, held as a NAMED REPLACEABLE BLOCK.
-#: Phase 4 SUBSTITUTES this rather than appending a wider authorization beside it:
-#: two conflicting directives leave the choice of which to honour outside the
-#: harness's control.
-STOP_AFTER_COMPOSE = """\
-## Scope of this run — STOP AFTER COMPOSE
-
-Run `/ipa-init` and then `/ipa-compose`. When `/ipa-compose` prints its completion
-report, STOP and report what was generated.
-
-Do NOT run `/ipa-prepare`, `/ipa-deploy`, or any `make` target from `scripts/`.
-Those create billable AWS resources and are out of scope for this run. Delegating to
-`/ipa-security` from `/ipa-compose` Step 1.5 IS in scope — it deploys two IAM roles,
-which are free.
-"""
-
-FULL_LIFECYCLE = """\
-## Scope of this run — FULL LIFECYCLE AUTHORIZED
-
-Run the complete IPA lifecycle: `/ipa-init`, `/ipa-compose`, `/ipa-prepare`, then
-`/ipa-deploy`. You are authorized to create billable AWS resources.
-
-When a skill asks a plain-text confirmation such as "Proceed? (yes/no):", answer
-`yes` and state briefly why you are proceeding. Do not stop to ask the operator:
-this run is unattended and nobody is watching the terminal.
-
-Report the deployed URLs when `/ipa-deploy` completes.
-"""
-
-
-def unattended_append(namespace: str, profile: str, scope: str) -> str:
- """The instruction appended to the preset system prompt."""
- return f"""\
-# Unattended integration-test run
-
-You are being driven by an automated integration test with no human watching. Never
-wait for operator input. Answer every `AskUserQuestion` and every plain-text
-confirmation yourself and keep going.
-
-## Pinned configuration — use these values exactly
-
-- **`APP_NAMESPACE` MUST be `{namespace}`.** Do not use `app`, and do not invent one.
- `/ipa-init`'s namespace question offers exactly one option, `"app" (Recommended)`,
- so you must supply `{namespace}` through the question's free-text ("Other") path.
- This value is how the test finds and removes what it created; a different namespace
- strands billable infrastructure under a name nothing looks for.
-- **`AWS_PROFILE` MUST be `{profile}`.** Do NOT choose "Skip", which is the
- recommended option: it omits `AWS_PROFILE` from `.env` entirely, and three
- downstream skills require it.
-- `AWS_REGION` = `{REGION}`, `APP_ENV` = `dev`, `APP_IAC` = `cloudformation`.
-
-## What to build
-
-A web app with a REST API and a background queue, with Cognito authentication.
-
-## Two rules that prevent unrecoverable mistakes
-
-**1. Never run a `make` target that writes `.env` and one that reads it in the same
-invocation.** Make loads `.env` via `-include` ONCE at parse time, so an invocation
-cannot see values its own `update-env` step wrote during that same invocation — they
-read as empty, and the resulting error looks like a template bug rather than a
-sequencing mistake. Always use two separate `make` invocations: one to sync the
-environment, then a fresh one to consume it.
-
-**2. Never recompute `CognitoDomainPrefix`. Always read `COGNITO_DOMAIN_PREFIX`
-from `.env`.** Recomputing it changes a property on `AWS::Cognito::UserPoolDomain`
-that CloudFormation treats as REPLACE, which destroys the live domain. That is not
-recoverable within this run and its cause is invisible in the resulting error.
-
-{scope}"""
-
-
-# --------------------------------------------------------------------------- #
-# Options
-# --------------------------------------------------------------------------- #
-
-
-def build_options(
- project: Path,
- resolver: AnswerResolver,
- transcript: Transcript,
- scope: str = STOP_AFTER_COMPOSE,
- max_budget_usd: float | None = None,
- max_turns: int | None = None,
-) -> "ClaudeAgentOptions":
- """Assemble the SDK options. See the module docstring for the five traps."""
- _require_sdk()
-
- def _answer(questions: list[dict[str, Any]], via: str) -> dict[str, str]:
- """Resolve one batch and record which channel delivered it.
-
- ``UnknownQuestion`` deliberately propagates: aborting with the question text
- beats guessing, because a guess writes a subtly wrong ``.env`` that still
- passes the init gate.
- """
- supplied = resolver.resolve(questions)
- transcript.record(
- "ask_user_question", via=via, questions=questions, answers=supplied
- )
- return supplied
-
- async def can_use_tool(
- tool_name: str, input_data: dict[str, Any], context: Any
- ) -> PermissionResultAllow:
- """Never fires under acceptEdits. A TRIPWIRE, not redundancy.
-
- If a future SDK switches channels, this keeps the run from silently falling
- through to skill defaults while reporting success -- and the transcript's
- ``via`` field makes the switch visible as a channel change rather than as a
- wrong namespace three steps later.
- """
- if tool_name != "AskUserQuestion":
- return PermissionResultAllow()
- questions = input_data.get("questions", [])
- return PermissionResultAllow(
- updated_input={
- # questions passed through UNCHANGED; only answers is added.
- "questions": questions,
- "answers": _answer(questions, via="can_use_tool"),
- },
- )
-
- async def pre_tool_hook(
- input_data: dict[str, Any], tool_use_id: str | None, context: Any
- ) -> dict[str, Any]:
- """Answer AskUserQuestion; pass every other tool through untouched."""
- if input_data.get("tool_name") != "AskUserQuestion":
- return {}
- questions = input_data.get("tool_input", {}).get("questions", [])
- return {
- "hookSpecificOutput": {
- "hookEventName": "PreToolUse",
- "permissionDecision": "allow",
- "updatedInput": {
- "questions": questions,
- "answers": _answer(questions, via="pre_tool_use_hook"),
- },
- }
- }
-
- return ClaudeAgentOptions(
- cwd=str(project),
- # Required for the temp project's .claude/skills/ipa-* to load at all.
- setting_sources=["project"],
- env={
- **os.environ,
- "CLAUDE_CODE_USE_BEDROCK": "1",
- # PINNED, not inherited. See trap 2 in the module docstring.
- "AWS_REGION": REGION,
- "AWS_DEFAULT_REGION": REGION,
- "ANTHROPIC_MODEL": MODEL,
- "AWS_PROFILE": resolver.profile,
- },
- model=MODEL,
- permission_mode="acceptEdits",
- can_use_tool=can_use_tool,
- # matcher=None and filter inside the hook: the reference's measured-working
- # form. A tool-name matcher adds a second place the filter can be wrong.
- hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])]},
- max_turns=max_turns if max_turns is not None else MAX_TURNS,
- max_budget_usd=(
- max_budget_usd if max_budget_usd is not None else MAX_BUDGET_USD
- ),
- system_prompt={
- "type": "preset",
- "preset": "claude_code",
- "append": unattended_append(resolver.namespace, resolver.profile, scope),
- },
- )
-
-
-def full_lifecycle_options(*args: Any, **kwargs: Any) -> "ClaudeAgentOptions":
- """The substitution: the wider scope AND the raised ceilings together.
-
- Both change as one. The compose ceilings are sized for a run that stops early, so
- a full lifecycle reaching them looks identical to a wedge.
-
- The spend ceiling comes from ``FULL_MAX_BUDGET_USD`` -- its own variable, separate
- from ``IPA_IT_MAX_BUDGET_USD``. The two differ threefold and each scenario sets its
- own inline, so a single shared variable set for the compose scenario would cap a
- deploy run at $5 and stop it mid-deployment.
-
- ``max_turns`` stays a literal, and the asymmetry is deliberate: a turn ceiling is a
- runaway-loop backstop, and making it configurable invites raising it to get past a
- wedge, which is the behaviour it exists to stop.
- """
- kwargs.setdefault("scope", FULL_LIFECYCLE)
- kwargs.setdefault("max_budget_usd", FULL_MAX_BUDGET_USD)
- kwargs.setdefault("max_turns", 600)
- return build_options(*args, **kwargs)
-
-
-# --------------------------------------------------------------------------- #
-# The run
-# --------------------------------------------------------------------------- #
-
-
-@dataclass
-class RunResult:
- """Measured facts about one drive. Never predicted."""
-
- cost_usd: float | None = None
- turns: int | None = None
- wall_clock_s: float | None = None
- stopped_by_ceiling: bool = False
- gates_seen: list[str] = field(default_factory=list)
- channels_used: set[str] = field(default_factory=set)
- final_text: str = ""
- result_payload: dict[str, Any] = field(default_factory=dict)
-
- #: How many times ``drive`` had to ask the agent to carry on after it ended its
- #: turn with the lifecycle unfinished. **Zero is the only good value** -- every
- #: nudge is a skill that stopped where it should have continued, and the whole
- #: reason this is a counter rather than a boolean is that it must be reportable.
- nudges: int = 0
-
- #: What was still missing at the last check, verbatim from ``is_complete``.
- #: Empty means the drive finished complete.
- incomplete_reason: str = ""
-
- #: EVERY ``ResultMessage`` cost, in order. ``cost_usd`` keeps only the last one,
- #: and a nudged drive produces several -- whether the SDK's figure is cumulative
- #: or per-query is not measured here, so the list is recorded rather than a sum
- #: inferred from an assumption about which it is.
- cost_reports: list[float] = field(default_factory=list)
-
-
-def _record_message(transcript: Transcript, message: Any, result: RunResult) -> None:
- """Record one SDK message, flattening the parts worth asserting on later."""
- kind = type(message).__name__
- texts: list[str] = []
- for block in getattr(message, "content", None) or []:
- text = getattr(block, "text", None)
- if text:
- texts.append(text)
- # NB: the payload key is `msg_kind`, not `kind` -- `record`'s own first parameter
- # is named `kind`, so a `kind=` payload key raises TypeError for multiple values.
- if texts:
- joined = "\n".join(texts)
- transcript.record("assistant_text", msg_kind=kind, text=joined)
- result.gates_seen += scan_for_gates(transcript, joined)
- result.final_text = joined
- else:
- transcript.record("message", msg_kind=kind, repr=repr(message)[:2000])
-
-
-async def drive(
- prompt: str,
- project: Path,
- resolver: AnswerResolver,
- transcript: Transcript,
- options: "ClaudeAgentOptions | None" = None,
- is_complete: "Callable[[RunResult], tuple[bool, str]] | None" = None,
- max_nudges: int = MAX_NUDGES,
-) -> RunResult:
- """Drive one session to completion with ONE long-lived client.
-
- See trap 3: a client per lifecycle stage closes its input stream before the
- answer callback can fire, producing a silent hang.
-
- ``is_complete`` is the CALLER's definition of "the lifecycle finished", returning
- ``(done, what_is_missing)``. It exists because ``ResultMessage(subtype='success',
- stop_reason='end_turn')`` is what the SDK reports for a run that stopped a tenth
- of the way through ``/ipa-compose`` -- an agent ending its turn is
- indistinguishable, from here, from an agent finishing the work. Measured twice at
- the same boundary (``/ipa-compose`` Step 1.5 delegating to ``/ipa-security``):
- both runs reported success having generated no artifacts.
-
- When the predicate says the work is unfinished the drive NUDGES -- sends a
- follow-up on the same client -- up to ``max_nudges`` times. This is deliberately
- a thin backstop and not a fix: each nudge is counted on the result and recorded in
- the transcript, so a run that only completed because it was nudged reports that
- fact rather than looking clean. A ceiling stop is never nudged; spending more on a
- run already stopped for spending is the one case where continuing is certainly
- wrong.
- """
- options = options or build_options(project, resolver, transcript)
- result = RunResult()
- started = time.monotonic()
-
- transcript.record(
- "run_start",
- namespace=resolver.namespace,
- profile=resolver.profile,
- region=REGION,
- model=MODEL,
- max_budget_usd=options.max_budget_usd,
- max_turns=options.max_turns,
- max_nudges=max_nudges,
- project=str(project),
- )
-
- try:
- async with ClaudeSDKClient(options=options) as client:
- pending = prompt
- while True:
- await client.query(pending)
- async for message in client.receive_response():
- _record_message(transcript, message, result)
- if type(message).__name__ == "ResultMessage":
- result.cost_usd = getattr(message, "total_cost_usd", None)
- if result.cost_usd is not None:
- result.cost_reports.append(result.cost_usd)
- result.turns = getattr(message, "num_turns", None)
- payload = getattr(message, "__dict__", {})
- result.result_payload = {
- k: v for k, v in payload.items() if not k.startswith("_")
- }
- subtype = str(getattr(message, "subtype", "") or "")
- # Distinguish "a ceiling stopped it" from "the agent finished".
- if "max_turns" in subtype or "budget" in subtype:
- result.stopped_by_ceiling = True
-
- done, missing = (
- (True, "") if is_complete is None else is_complete(result)
- )
- result.incomplete_reason = "" if done else missing
- if done or result.stopped_by_ceiling:
- break
- if result.nudges >= max_nudges:
- transcript.record(
- "nudge_exhausted", after=result.nudges, missing=missing
- )
- break
- result.nudges += 1
- transcript.record("nudge", attempt=result.nudges, missing=missing)
- # Printed as well as recorded: on a drive whose only other output is a
- # blank screen, a nudge is the one thing a watching maintainer can act
- # on -- it names a skill that stopped early, while the run continues.
- print(f"\nNUDGE {result.nudges}/{max_nudges}: {missing}")
- pending = NUDGE_PROMPT.format(missing=missing)
- finally:
- result.wall_clock_s = time.monotonic() - started
- transcript.record(
- "run_end",
- cost_usd=result.cost_usd,
- cost_reports=result.cost_reports,
- turns=result.turns,
- wall_clock_s=round(result.wall_clock_s, 1),
- stopped_by_ceiling=result.stopped_by_ceiling,
- nudges=result.nudges,
- incomplete_reason=result.incomplete_reason,
- gates_seen=sorted(set(result.gates_seen)),
- )
-
- # Which channel actually delivered the answers -- read back from the transcript
- # so it reflects what happened rather than what was registered.
- for line in transcript.path.read_text().splitlines():
- try:
- event = json.loads(line)
- except json.JSONDecodeError:
- continue
- if event.get("kind") == "ask_user_question" and event.get("via"):
- result.channels_used.add(event["via"])
-
- return result
-
-
-COMPOSE_PROMPT = (
- "Set up this project with IPA and compose a deployment for: "
- "Web App with REST API and background queue with Cognito authentication. "
- "Run /ipa-init first, then /ipa-compose."
-)
-
-FULL_PROMPT = (
- "Set up this project with IPA and deploy: "
- "Web App with REST API and background queue with Cognito authentication. "
- "Run /ipa-init, then /ipa-compose, then /ipa-prepare, then /ipa-deploy."
-)
-
-#: Sent when a drive ends its turn with the lifecycle unfinished. States WHAT is
-#: missing -- supplied by the scenario's ``is_complete`` -- rather than "continue",
-#: because the agent has just reported success and needs the specific gap, not
-#: encouragement. It deliberately does not name a skill: which step is unfinished is
-#: the agent's to work out from the state on disk, and telling it to re-run a named
-#: skill is how a nudge turns into a second composition over a half-composed project.
-NUDGE_PROMPT = (
- "You ended your turn but the lifecycle is not finished: {missing}\n\n"
- "Nothing has gone wrong and nobody is waiting -- this run is unattended. Check "
- "what is actually on disk and in AWS, work out which step you stopped in the "
- "middle of, and carry on from there through to the end of the lifecycle you were "
- "asked for. Do not start over and do not re-run a step that already completed."
-)
diff --git a/internal/integration-tests/harness/fixtures.py b/internal/integration-tests/harness/fixtures.py
deleted file mode 100644
index 1923b3f..0000000
--- a/internal/integration-tests/harness/fixtures.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""Session fixtures shared by every scenario, plus the two rules that keep them safe.
-
-Loaded as a **pytest plugin** (``-p harness.fixtures`` in each scenario's ``pytest.ini``),
-not imported and not copied. Three reasons, in increasing order of importance:
-
-* ``pytest_runtest_makereport`` is a hook, and pytest collects hooks from ``conftest.py``
- files and from plugins -- never from an ordinary imported module.
-* Copying would mean each scenario carries its own drift-prone duplicate.
-* **The preservation rule below decides whether to delete the only documented means of
- teardown.** Two copies of that rule will diverge, and the divergence nobody notices
- is the one that deletes more.
-
-**One ``_unmet`` helper decides every prerequisite.** It imports ``preflight``, so a probe
-added there takes effect everywhere without any test being edited. A check duplicated in a
-second place is one a later-added probe can be forgotten from, and the consequence is
-asymmetric: a skip is right on a maintainer's laptop, where a stopped Docker daemon is an
-environment gap rather than a defect in the code under test, and **catastrophic in a
-pipeline, where a build that skips every billable test exits 0 having tested nothing**.
-``IPA_IT_STRICT_PREFLIGHT`` flips that one helper.
-
-**The preservation rule is the single most consequential thing in this file.** The temp
-project is preserved when the run's stacks are STANDING -- never on the basis of tests
-passing -- and it is preserved when the standing check itself errors. The temp project
-holds ``.env`` and ``scripts/*.mk``, the only documented means of teardown, so deleting
-them after a passing run whose stacks still stand removes the mechanism for removing what
-is still costing money. And a listing that errors is exactly the case where standing
-stacks are most likely: the same credential or network fault may have broken the teardown
-too.
-"""
-
-from __future__ import annotations
-
-import os
-import shutil
-from pathlib import Path
-
-import pytest
-
-from harness import answers, aws, driver, preflight, standing, workspace
-
-REGION = aws.REGION
-ENV = "dev"
-
-#: The harness root -- one level above this module. Run artifacts land HERE, not inside
-#: ``harness/``: ``.gitignore``'s globs, ``README.md`` and the deployed buildspec's
-#: artifact paths all name this location, and none of them fails loudly if it moves.
-HARNESS_ROOT = Path(__file__).resolve().parents[1]
-
-
-# --------------------------------------------------------------------------- #
-# The ONE prerequisite helper
-# --------------------------------------------------------------------------- #
-
-
-def _strict() -> bool:
- return os.environ.get("IPA_IT_STRICT_PREFLIGHT") == "1"
-
-
-def _unmet(*probe_names: str) -> None:
- """Skip -- or, under strict mode, FAIL -- when any named probe reports negative.
-
- The single decision point. Nothing else in the harness reads
- ``IPA_IT_STRICT_PREFLIGHT``, and no test writes its own probe check.
- """
- for name in probe_names:
- ok, detail = preflight.run_probe(name)
- if ok:
- continue
- message = f"prerequisite {name!r} unmet: {detail}"
- if _strict():
- pytest.fail(f"{message} (IPA_IT_STRICT_PREFLIGHT=1)")
- pytest.skip(message)
-
-
-# --------------------------------------------------------------------------- #
-# Session fixtures
-# --------------------------------------------------------------------------- #
-
-
-@pytest.fixture(scope="session")
-def credentials() -> str:
- """The credential RESOLUTION METHOD, not a profile name.
-
- Was ``aws_profile``, and the rename is the point: the harness authenticates from the
- default credential chain, so what there is to report is which link answered --
- ``env``, ``sso``, ``container-role``, ``iam-role``. Anything needing a profile
- *name* wants ``harness.aws.profile_for_env`` instead, which answers a different
- question and is never used to build a session.
- """
- _unmet("credentials_resolvable", "sts_caller_identity")
- creds = aws.session().get_credentials()
- return getattr(creds, "method", "unknown")
-
-
-@pytest.fixture(scope="session")
-def resolver(credentials: str) -> answers.AnswerResolver:
- """One resolver per session, so deploy and teardown share a namespace.
-
- ``IPA_IT_NAMESPACE`` is passed THROUGH ``make_namespace`` (inside
- ``AnswerResolver``), which is what validates an injected value -- the assertions live
- in the generator, so a caller writing ``injected or generate()`` would never check
- it.
-
- Depends on ``credentials`` for ORDERING, not for its value: the credential probes
- must gate the session before anything drives an agent. The profile the resolver
- carries comes from ``AWS_PROFILE`` via ``resolver_from_env``, which raises when it is
- unset.
- """
- return answers.resolver_from_env()
-
-
-@pytest.fixture(scope="session")
-def namespace(resolver: answers.AnswerResolver) -> str:
- return resolver.namespace
-
-
-@pytest.fixture(scope="session")
-def transcript(namespace: str, tmp_path_factory) -> driver.Transcript:
- path = HARNESS_ROOT / f"transcript-{namespace}.jsonl"
- return driver.Transcript(path=path)
-
-
-@pytest.fixture(scope="session")
-def temp_project(
- resolver: answers.AnswerResolver,
- namespace: str,
- credentials: str,
- transcript: driver.Transcript,
- tmp_path_factory,
-):
- """Build the temp project, then apply the preservation rule at teardown."""
- _unmet("claude_agent_sdk_importable", "docker_daemon", "npm_present", "uv_present")
-
- dest = tmp_path_factory.mktemp("ipa-project", numbered=True) / "project"
- source = workspace.build(dest)
- transcript.record_source(source)
- print(f"\ntemp project: {source.describe()}")
-
- yield source
-
- # --- The preservation rule ------------------------------------------------
- # Based on STACKS STANDING, never on tests passing. Fail-safe: preserve when the
- # listing itself errors.
- stacks, error = standing.live_stacks(namespace, REGION)
-
- if error:
- print(
- f"\nPRESERVING {source.root}: could not determine whether stacks are "
- f"standing ({error}). Preserving on an errored listing is deliberate -- "
- "it is exactly the case where standing stacks are most likely."
- )
- return
-
- if stacks:
- env_values = standing.parse_env(source.root / ".env")
- standing.report_everywhere(
- namespace, ENV, REGION, credentials, stacks, env_values,
- redact=transcript.redact,
- )
- print(
- f"PRESERVING {source.root}: it holds .env and scripts/*.mk, the only "
- "documented means of teardown."
- )
- return
-
- # Nothing standing -- safe to remove regardless of whether tests passed.
- shutil.rmtree(source.root, ignore_errors=True)
- print(f"\nno stacks standing under {namespace}; removed {source.root}")
-
-
-# --------------------------------------------------------------------------- #
-# Reporting
-# --------------------------------------------------------------------------- #
-
-
-@pytest.hookimpl(tryfirst=True, hookwrapper=True)
-def pytest_runtest_makereport(item, call):
- """Expose each phase's outcome on the item.
-
- Collected because this module is loaded as a PLUGIN -- an ordinary imported module's
- hooks are never registered. Note that nothing in the preservation rule consults it:
- preservation is decided by standing stacks, deliberately not by test outcomes.
- """
- outcome = yield
- report = outcome.get_result()
- setattr(item, f"report_{report.when}", report)
diff --git a/internal/integration-tests/harness/lifecycle.py b/internal/integration-tests/harness/lifecycle.py
deleted file mode 100644
index 7e23b6c..0000000
--- a/internal/integration-tests/harness/lifecycle.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Has the lifecycle actually finished? Answered from disk and AWS, never from the agent.
-
-``ResultMessage(subtype='success', stop_reason='end_turn')`` is what the SDK reports for
-a drive that stopped a tenth of the way through ``/ipa-compose`` — measured twice, at the
-same boundary, where compose delegates to ``/ipa-security``. So the run's own account of
-itself cannot be the completion signal, and these predicates read the artifacts on disk
-and the stacks in the account instead.
-
-**Here rather than in each scenario's ``conftest.py``** because a scenario conftest is
-not reachable by the offline suite, and these are the functions that decide whether a
-billable run gets re-queried and whether twenty-one assertions run against nothing. They
-are pure enough to test for free: one takes a path, the other takes a lister.
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-from harness import artifacts, standing
-
-
-def compose_state(project: Path) -> tuple[bool, str]:
- """``(finished, what_is_missing)`` for the compose scope.
-
- Presence on disk is the whole test. An agent that stopped early cannot report
- artifacts it did not write, which is exactly what makes this a usable signal where
- ``subtype='success'`` is not.
- """
- missing = artifacts.missing_artifacts(project)
- if not missing:
- return True, ""
- return False, (
- f"{len(missing)} of /ipa-compose's {len(artifacts.EXPECTED_ARTIFACTS)} "
- f"artifacts are still absent ({', '.join(missing)}), so composition is "
- "unfinished."
- )
-
-
-def deploy_state(
- project: Path,
- namespace: str,
- env: str,
- region: str,
- *,
- strict: bool = False,
- lister=standing.live_stacks,
-) -> tuple[bool, str]:
- """``(finished, what_is_missing)`` for the full lifecycle.
-
- Both halves are load-bearing. Artifacts alone would call a composed-but-undeployed
- project finished; stacks alone would be blind to a deployment made by hand.
-
- ``strict`` decides what an ERRORED stack listing means, and the two callers need
- opposite answers from the same three-way state — listed, empty, unknown:
-
- * The nudge decision (``strict=False``) treats unknown as finished. Re-querying the
- agent because a credential blip broke one ``list-stacks`` spends money on a
- deployment that may already be complete.
- * The gate (``strict=True``) treats unknown as a failure. Passing on an errored
- listing lets the twenty-one downstream assertions fail one at a time against
- nothing, which is the whole thing the gate exists to prevent.
-
- ``lister`` is injected so the offline suite can exercise all three states without a
- credential; nothing in the scenarios passes it.
- """
- done, missing = compose_state(project)
- if not done:
- return False, f"{missing} Nothing could have been deployed."
-
- live, error = lister(namespace, region)
- if error:
- if not strict:
- return True, ""
- return False, (
- f"could not list stacks under {namespace!r}, so whether the deployment "
- f"completed is unknown: {error}"
- )
-
- found = sorted(name for name, _ in live)
- absent = standing.absent_tiers(namespace, env, found)
- if absent:
- return False, (
- f"{len(absent)} of {len(standing.EXPECTED_TIERS)} stacks are not deployed "
- f"({', '.join(absent)}); live under {namespace!r}: {found or 'none'}."
- )
- return True, ""
diff --git a/internal/integration-tests/harness/preflight.py b/internal/integration-tests/harness/preflight.py
deleted file mode 100644
index 63dffa1..0000000
--- a/internal/integration-tests/harness/preflight.py
+++ /dev/null
@@ -1,693 +0,0 @@
-"""Environment and account probes for the IPA integration-test harness.
-
-The contract, and it is the whole point of this module:
-
-* **Every probe returns ``(ok: bool, detail: str)`` and never raises.** A probe
- that cannot reach its subject reports ``(False, )``. This
- is what lets two callers apply opposite policies to the same value: an
- interactive maintainer reads a negative outcome as an environment gap, and a
- pipeline reads it as a failure. Neither needs a ``try``.
-* **The detail carries the underlying error verbatim, never a paraphrase.**
- ``iam:CreateRole`` can be denied by a service control policy, by a permission
- boundary, or by the named-IAM capability specifically, and the three read
- almost identically once summarised.
-* **No pytest import, and no side effects at import time.** ``conftest.py``
- imports this module in a later phase, and ``import preflight`` must create no
- AWS resource and run no probe.
-
-Two of the nine probes exist to retire unknowns that could make the whole harness
-impossible, and both cost cents rather than a billable run:
-
-* ``probe_iam_create_role`` — whether a two-role stack with explicit role names
- deploys under ``CAPABILITY_NAMED_IAM``.
-* ``probe_cognito_token`` — whether a user pool with
- ``AdvancedSecurityMode: ENFORCED`` (``infra/cfn/cognito/cognito.yml``) still
- answers a programmatic ``InitiateAuth`` with tokens. The natural fallback is
- unavailable: ``ALLOW_ADMIN_USER_PASSWORD_AUTH`` is absent from the pool
- client's ``ExplicitAuthFlows``, so ``AdminInitiateAuth`` would need a change
- to a customer-facing template.
-
-Run it directly::
-
- python -m preflight
-"""
-
-from __future__ import annotations
-
-import base64
-import hashlib
-import json
-import os
-import secrets
-import string
-import subprocess # nosec B404 - argv lists only, shell=False; see .ash/ash.yaml
-import sys
-from pathlib import Path
-
-from botocore.exceptions import BotoCoreError, ClientError
-
-from harness import aws
-
-# --------------------------------------------------------------------------- #
-# Configuration. Every value is overridable from the environment so Phase 5's
-# CI seam has somewhere to attach without editing this file.
-#
-# There is deliberately NO profile constant here. Credentials come from the
-# default chain via ``harness.aws.session`` -- see ``_session`` below.
-# --------------------------------------------------------------------------- #
-
-REGION = aws.REGION
-
-PROBE_NAMESPACE = "itprobe"
-PROBE_ENV = "dev"
-
-WORKER_MODEL_ID = os.environ.get(
- "IPA_IT_WORKER_MODEL", "anthropic.claude-3-haiku-20240307-v1:0"
-)
-DRIVER_MODEL_ID = os.environ.get(
- "IPA_IT_DRIVER_MODEL", "us.anthropic.claude-sonnet-4-20250514-v1:0"
-)
-
-# HITL default K1.A: ReadOnlyAccess, not PowerUserAccess.
-#
-# /ipa-deploy Step 1.5 checks only that {ns}-{env}-security reports
-# CREATE_COMPLETE or UPDATE_COMPLETE; it never assumes either role, and
-# APP_BUILDER_ROLE_ARN appears zero times in MAKEFILE_TEMPLATES.md. So
-# ReadOnlyAccess satisfies that gate identically with a strictly smaller IAM
-# footprint, and it removes the tension with .context/README.md's "no wildcard
-# IAM resource ARNs" constraint that PowerUserAccess would introduce.
-PROBE_MANAGED_POLICY_ARN = "arn:aws:iam::aws:policy/ReadOnlyAccess"
-
-# Repository root, resolved from this file rather than from the caller's cwd.
-#
-# ``parents[3]``, not ``parents[2]``: this module moved down one level into
-# ``harness/``, so the hop count grew by one -- ``harness/`` ->
-# ``integration-tests/`` -> ``internal/`` -> repo root. At ``parents[2]`` the Cognito
-# template below resolves to ``internal/infra/cfn/...``, which does not exist, and the
-# blocking check fails on a missing file rather than on anything about the account.
-REPO_ROOT = Path(__file__).resolve().parents[3]
-COGNITO_TEMPLATE = REPO_ROOT / "infra" / "cfn" / "cognito" / "cognito.yml"
-
-_IAM_STACK = f"{PROBE_NAMESPACE}-{PROBE_ENV}-security"
-_COGNITO_STACK = f"{PROBE_NAMESPACE}-{PROBE_ENV}-cognito"
-
-# Mirrors /ipa-security's generated iam.yml (SKILL.md:296-341) structurally:
-# two roles with EXPLICIT RoleNames -- which is what forces
-# CAPABILITY_NAMED_IAM -- one trusting the account root and one trusting
-# codebuild.amazonaws.com, each attaching a single managed-policy ARN passed as
-# a parameter.
-#
-# Structural fidelity is the point. A probe that created one unnamed role would
-# succeed in an account that denies exactly what /ipa-security needs.
-#
-# Written at probe time rather than committed as a template file: a committed
-# copy would be a second definition of /ipa-security's role shape, and it would
-# drift silently.
-_IAM_PROBE_TEMPLATE = """\
-AWSTemplateFormatVersion: '2010-09-09'
-Description: 'IPA integration-test preflight probe -- mirrors /ipa-security iam.yml'
-
-Parameters:
- Namespace:
- Type: String
- Environment:
- Type: String
- AccountId:
- Type: String
- ManagedPolicyArn:
- Type: String
-
-Resources:
- BuilderExecutionRole:
- Type: AWS::IAM::Role
- Properties:
- RoleName: !Sub "${Namespace}-${Environment}-builder"
- AssumeRolePolicyDocument:
- Version: "2012-10-17"
- Statement:
- - Effect: Allow
- Principal:
- AWS: !Sub "arn:aws:iam::${AccountId}:root"
- Action: "sts:AssumeRole"
- ManagedPolicyArns:
- - !Ref ManagedPolicyArn
-
- CodeBuildExecutionRole:
- Type: AWS::IAM::Role
- Properties:
- RoleName: !Sub "${Namespace}-${Environment}-codebuild"
- AssumeRolePolicyDocument:
- Version: "2012-10-17"
- Statement:
- - Effect: Allow
- Principal:
- Service: codebuild.amazonaws.com
- Action: "sts:AssumeRole"
- ManagedPolicyArns:
- - !Ref ManagedPolicyArn
-
-Outputs:
- BuilderRoleArn:
- Value: !GetAtt BuilderExecutionRole.Arn
- CodeBuildRoleArn:
- Value: !GetAtt CodeBuildExecutionRole.Arn
-"""
-
-
-# --------------------------------------------------------------------------- #
-# Helpers
-# --------------------------------------------------------------------------- #
-
-
-def _session():
- """One session, passed to every AWS probe. Region PINNED, credentials AMBIENT.
-
- The region is explicit because this shell exports ``AWS_REGION=us-west-2``, and an
- inherited region would silently probe the wrong account and report it clean --
- clean because nothing was ever there, which is indistinguishable from a pass.
-
- The credentials are ambient BY DESIGN, which is the opposite of what this docstring
- used to argue. The default chain reads the environment or an SSO cache on a
- workstation and the ECS container role inside CodeBuild, so one code path serves
- both and no maintainer's profile name appears in source. The region argument was
- never the reason to name a profile; only the region needed pinning.
- """
- return aws.session(REGION)
-
-
-def _run(argv: list[str], timeout: int = 30) -> tuple[bool, str]:
- """Run ``argv`` with ``shell=False`` and fold every failure into a pair.
-
- An argv list means no shell quoting applies, so paths containing spaces pass
- through intact -- which is what makes the B603 suppression in
- ``.ash/ash.yaml`` true rather than merely written.
- """
- try:
- proc = subprocess.run( # nosec B603 B607 - argv list, shell=False
- argv,
- capture_output=True,
- text=True,
- timeout=timeout,
- check=False,
- )
- except FileNotFoundError:
- return False, f"{argv[0]}: not found on PATH"
- except subprocess.TimeoutExpired:
- return False, f"{argv[0]}: timed out after {timeout}s"
- except OSError as exc: # pragma: no cover - defensive
- return False, f"{argv[0]}: {exc}"
- if proc.returncode != 0:
- detail = (proc.stderr or proc.stdout or "").strip()
- return False, f"exit {proc.returncode}: {detail}"
- return True, (proc.stdout or "").strip()
-
-
-def _aws_error(exc: Exception) -> str:
- """Render an AWS exception with its verbatim message, never a paraphrase."""
- if isinstance(exc, ClientError):
- err = exc.response.get("Error", {})
- return f"{err.get('Code', 'ClientError')}: {err.get('Message', str(exc))}"
- if isinstance(exc, BotoCoreError):
- return f"{type(exc).__name__}: {exc}"
- return f"{type(exc).__name__}: {exc}"
-
-
-def _stack_failure_reasons(cfn, stack_name: str) -> str:
- """Read a stack's failure events and return their reasons verbatim.
-
- This is the single most expensive thing to lose in this module: a service
- control policy denial, a permission boundary denial, and a named-IAM
- capability denial read almost identically once summarised.
- """
- try:
- events = cfn.describe_stack_events(StackName=stack_name)["StackEvents"]
- except Exception as exc: # noqa: BLE001 - diagnostics must not raise
- return f"(could not read stack events: {_aws_error(exc)})"
- reasons = [
- f"{e.get('LogicalResourceId')}: {e['ResourceStatusReason']}"
- for e in events
- if e.get("ResourceStatusReason")
- and "FAILED" in str(e.get("ResourceStatus", ""))
- ]
- return " | ".join(reasons[:5]) if reasons else "(no failure reason recorded)"
-
-
-def _delete_stack(cfn, stack_name: str) -> tuple[bool, str]:
- """Delete a stack and wait. Returns the standing stack name on timeout."""
- try:
- cfn.delete_stack(StackName=stack_name)
- cfn.get_waiter("stack_delete_complete").wait(
- StackName=stack_name,
- WaiterConfig={"Delay": 10, "MaxAttempts": 60},
- )
- return True, f"{stack_name} deleted"
- except Exception as exc: # noqa: BLE001 - teardown must not raise
- return False, f"{stack_name} may still be standing: {_aws_error(exc)}"
-
-
-def _generated_password(length: int = 20) -> str:
- """A password satisfying all four required character classes, by construction.
-
- ``cognito.yml``'s ``Policies.PasswordPolicy`` requires lowercase, uppercase,
- digits and symbols -- all four. Built by class rather than by rejection
- sampling, so it cannot fail intermittently.
-
- Held in memory only. Never written to any file.
- """
- symbols = "!@#$%^&*()-_=+"
- pools = [string.ascii_lowercase, string.ascii_uppercase, string.digits, symbols]
- chars = [secrets.choice(p) for p in pools]
- alphabet = "".join(pools)
- chars += [secrets.choice(alphabet) for _ in range(length - len(pools))]
- # Shuffle without Random(): secrets-driven Fisher-Yates.
- for i in range(len(chars) - 1, 0, -1):
- j = secrets.randbelow(i + 1)
- chars[i], chars[j] = chars[j], chars[i]
- return "".join(chars)
-
-
-def _jwt_payload(token: str) -> dict:
- """Base64-decode a JWT's payload WITHOUT verifying its signature.
-
- Deliberately unverified, and narrowly so: this token was minted seconds
- earlier by this module against the pool it just deployed, and the only claim
- read is compared to a value from that same deployment. Verifying would add a
- JWKS fetch and a crypto dependency to establish something already known.
-
- Never a pattern for anything that trusts an inbound token.
- """
- segment = token.split(".")[1]
- segment += "=" * (-len(segment) % 4)
- return json.loads(base64.urlsafe_b64decode(segment))
-
-
-def _domain_prefix(account_id: str) -> str:
- """``itprobe-dev-``.
-
- Deterministic per account so it is diagnosable, distinct across accounts so
- two accounts do not collide on a globally-unique name, and containing none of
- ``cognito``, ``aws`` or ``amazon`` -- which ``cognito.yml``'s own comment
- records that Cognito rejects.
- """
- digest = hashlib.sha256(account_id.encode()).hexdigest()[:8]
- return f"{PROBE_NAMESPACE}-{PROBE_ENV}-{digest}"
-
-
-# --------------------------------------------------------------------------- #
-# Cheap probes -- binaries and imports, no AWS call
-# --------------------------------------------------------------------------- #
-
-
-def probe_credentials_resolvable() -> tuple[bool, str]:
- """The default credential chain yields credentials, and WHICH LINK answered.
-
- Reporting only that credentials resolved would hide the interesting failure. The
- two environments this harness runs in resolve by different links -- ``env`` or
- ``sso`` on a workstation, ``container-role`` in CodeBuild -- and the case worth
- catching is resolving via the wrong one: a CI build answering from a long-lived
- environment key instead of its task role has resolved credentials successfully and
- is still misconfigured. Naming the link is what makes that visible in the row.
- """
- try:
- creds = _session().get_credentials()
- except Exception as exc: # noqa: BLE001
- return False, f"credential chain: {_aws_error(exc)}"
- if creds is None:
- return False, "credential chain yielded no credentials"
- return True, f"resolved by {creds.method!r} in {REGION}"
-
-
-def probe_sts_caller_identity() -> tuple[bool, str]:
- """``sts:GetCallerIdentity`` succeeds.
-
- Reports the account id and caller ARN, because those are the two facts
- ``DECISIONS.md`` is required to name.
- """
- try:
- ident = _session().client("sts").get_caller_identity()
- except Exception as exc: # noqa: BLE001
- return False, _aws_error(exc)
- return True, f"account={ident['Account']} arn={ident['Arn']}"
-
-
-def probe_claude_agent_sdk_importable() -> tuple[bool, str]:
- """``import claude_agent_sdk`` succeeds.
-
- A negative outcome is expected before Phase 2 declares the optional extra,
- and reads as a missing optional dependency rather than a failure of this
- module.
- """
- try:
- import claude_agent_sdk # noqa: PLC0415
- except ImportError as exc:
- return False, f"optional dependency not installed: {exc}"
- version = getattr(claude_agent_sdk, "__version__", "unknown version")
- return True, f"claude_agent_sdk {version}"
-
-
-def probe_docker_daemon() -> tuple[bool, str]:
- """The Docker daemon is answering.
-
- Reads a SERVER-only field. Deliberately NOT an exit-code check: ``docker
- info`` exits 0 with a partial payload when the daemon is down -- the client
- half of the output is still valid -- so an exit-code check reports a working
- daemon that is not there, and the failure resurfaces twenty minutes later
- inside ``build-rest-lambda``.
-
- Empty output is treated as a stopped daemon, which is the safe direction: a
- future format change yields a false negative (a skip), never a false positive
- that costs a container build.
- """
- ok, out = _run(["docker", "info", "--format", "{{.ServerVersion}}"])
- if not ok:
- return False, f"daemon not responding: {out}"
- if not out.strip():
- return False, "daemon not responding: docker info returned no ServerVersion"
- return True, f"docker server {out.strip()}"
-
-
-def probe_npm_present() -> tuple[bool, str]:
- """``npm`` is on PATH. Reports the resolved version, not merely presence."""
- ok, out = _run(["npm", "--version"])
- return (True, f"npm {out}") if ok else (False, out)
-
-
-def probe_uv_present() -> tuple[bool, str]:
- """``uv`` is on PATH. Reports the resolved version, not merely presence."""
- ok, out = _run(["uv", "--version"])
- return (True, out) if ok else (False, out)
-
-
-def _probe_bedrock(model_id: str, label: str) -> tuple[bool, str]:
- """Prove invoke access to ``model_id``.
-
- Reports access-denied distinctly from model-not-found, because the remedies
- differ: one is an IAM or model-access-request problem, the other is a wrong
- model id or a region without that model.
- """
- body = json.dumps(
- {
- "anthropic_version": "bedrock-2023-05-31",
- "max_tokens": 1,
- "messages": [{"role": "user", "content": "."}],
- }
- )
- try:
- _session().client("bedrock-runtime").invoke_model(
- modelId=model_id, body=body, contentType="application/json"
- )
- except ClientError as exc:
- code = exc.response.get("Error", {}).get("Code", "")
- message = exc.response.get("Error", {}).get("Message", str(exc))
- if code in ("AccessDeniedException", "UnauthorizedOperation"):
- return False, f"{label}: ACCESS DENIED -- {message}"
- if code in ("ResourceNotFoundException", "ValidationException"):
- return False, f"{label}: MODEL NOT FOUND/INVALID -- {message}"
- return False, f"{label}: {code}: {message}"
- except Exception as exc: # noqa: BLE001
- return False, f"{label}: {_aws_error(exc)}"
- return True, f"{label}: invoke ok ({model_id})"
-
-
-def probe_bedrock_worker_model() -> tuple[bool, str]:
- """Invoke access to the model the queue worker uses."""
- return _probe_bedrock(WORKER_MODEL_ID, "worker model")
-
-
-def probe_bedrock_driver_model() -> tuple[bool, str]:
- """Invoke access to the model the driver session itself runs on."""
- return _probe_bedrock(DRIVER_MODEL_ID, "driver model")
-
-
-# --------------------------------------------------------------------------- #
-# Blocking probe 1: named IAM role creation
-# --------------------------------------------------------------------------- #
-
-
-def probe_iam_create_role() -> tuple[bool, str]:
- """Deploy and delete a two-role stack mirroring /ipa-security's iam.yml.
-
- Reading a policy document cannot establish this. The account may deny
- ``iam:CreateRole``, or deny ``CAPABILITY_NAMED_IAM`` specifically, and either
- failure makes the whole harness impossible.
- """
- session = _session()
- cfn = session.client("cloudformation")
- try:
- account_id = session.client("sts").get_caller_identity()["Account"]
- except Exception as exc: # noqa: BLE001
- return False, f"could not resolve account id: {_aws_error(exc)}"
-
- created = False
- try:
- cfn.create_stack(
- StackName=_IAM_STACK,
- TemplateBody=_IAM_PROBE_TEMPLATE,
- Parameters=[
- {"ParameterKey": "Namespace", "ParameterValue": PROBE_NAMESPACE},
- {"ParameterKey": "Environment", "ParameterValue": PROBE_ENV},
- {"ParameterKey": "AccountId", "ParameterValue": account_id},
- {
- "ParameterKey": "ManagedPolicyArn",
- "ParameterValue": PROBE_MANAGED_POLICY_ARN,
- },
- ],
- # Explicit RoleNames are what force this capability, so the probe
- # must pass it -- otherwise it tests a different thing than
- # /ipa-security does.
- Capabilities=["CAPABILITY_NAMED_IAM"],
- )
- created = True
- cfn.get_waiter("stack_create_complete").wait(
- StackName=_IAM_STACK,
- WaiterConfig={"Delay": 10, "MaxAttempts": 60},
- )
- except Exception as exc: # noqa: BLE001
- reasons = _stack_failure_reasons(cfn, _IAM_STACK) if created else ""
- detail = f"{_aws_error(exc)}"
- if reasons:
- detail = f"{detail} || CFN: {reasons}"
- return False, detail
- finally:
- # Unconditional teardown, so a probe that fails midway still cleans up.
- if created:
- _delete_stack(cfn, _IAM_STACK)
-
- return True, (
- f"two named roles created and deleted under {PROBE_NAMESPACE} "
- f"with {PROBE_MANAGED_POLICY_ARN.rsplit('/', 1)[-1]} "
- f"and CAPABILITY_NAMED_IAM"
- )
-
-
-# --------------------------------------------------------------------------- #
-# Blocking probe 2: a programmatic Cognito identity token
-# --------------------------------------------------------------------------- #
-
-
-def probe_cognito_token() -> tuple[bool, str]:
- """Deploy the project's own Cognito template and mint an IdToken from it.
-
- Deploying ``infra/cfn/cognito/cognito.yml`` unchanged is the only way to test
- the property in question: ``AdvancedSecurityMode: ENFORCED`` is a property of
- THAT template, and a hand-written pool without it would pass while proving
- nothing.
-
- There is no fallback. ``ALLOW_ADMIN_USER_PASSWORD_AUTH`` is absent from the
- pool client's ``ExplicitAuthFlows``, so ``AdminInitiateAuth`` would require a
- change to a customer-facing deliverable.
- """
- if not COGNITO_TEMPLATE.is_file():
- return False, f"template not found: {COGNITO_TEMPLATE}"
-
- session = _session()
- cfn = session.client("cloudformation")
- idp = session.client("cognito-idp")
-
- try:
- account_id = session.client("sts").get_caller_identity()["Account"]
- except Exception as exc: # noqa: BLE001
- return False, f"could not resolve account id: {_aws_error(exc)}"
-
- prefix = _domain_prefix(account_id)
- created = False
- notes: list[str] = []
-
- try:
- cfn.create_stack(
- StackName=_COGNITO_STACK,
- TemplateBody=COGNITO_TEMPLATE.read_text(),
- Parameters=[
- {"ParameterKey": "Namespace", "ParameterValue": PROBE_NAMESPACE},
- {"ParameterKey": "Environment", "ParameterValue": PROBE_ENV},
- {"ParameterKey": "CognitoDomainPrefix", "ParameterValue": prefix},
- # DeletionProtection left at its INACTIVE default on purpose:
- # passing ACTIVE would strand this stack.
- ],
- Capabilities=["CAPABILITY_IAM"],
- )
- created = True
- cfn.get_waiter("stack_create_complete").wait(
- StackName=_COGNITO_STACK,
- WaiterConfig={"Delay": 10, "MaxAttempts": 90},
- )
-
- outputs = {
- o["OutputKey"]: o["OutputValue"]
- for o in cfn.describe_stacks(StackName=_COGNITO_STACK)["Stacks"][0].get(
- "Outputs", []
- )
- }
- pool_id = outputs.get("UserPoolId")
- client_id = outputs.get("UserPoolClientId")
- if not pool_id or not client_id:
- return False, f"stack outputs missing UserPoolId/UserPoolClientId: {outputs}"
-
- username = f"itprobe-{secrets.token_hex(4)}@example.invalid"
- password = _generated_password() # in memory only; never written anywhere
-
- idp.admin_create_user(
- UserPoolId=pool_id,
- Username=username,
- # No mail to a non-existent address.
- MessageAction="SUPPRESS",
- UserAttributes=[
- {"Name": "email", "Value": username},
- {"Name": "email_verified", "Value": "true"},
- ],
- )
- # Permanent=True clears FORCE_CHANGE_PASSWORD, which would otherwise
- # answer InitiateAuth with a NEW_PASSWORD_REQUIRED challenge.
- idp.admin_set_user_password(
- UserPoolId=pool_id, Username=username, Password=password, Permanent=True
- )
-
- auth = idp.initiate_auth(
- ClientId=client_id,
- AuthFlow="USER_PASSWORD_AUTH",
- AuthParameters={"USERNAME": username, "PASSWORD": password},
- )
-
- if "ChallengeName" in auth:
- # Recorded verbatim: adaptive authentication challenging this call is
- # the finding that would block the whole feature.
- return False, (
- f"adaptive auth challenged InitiateAuth -- "
- f"ChallengeName={auth['ChallengeName']!r} "
- f"ChallengeParameters={auth.get('ChallengeParameters')!r}"
- )
-
- id_token = auth.get("AuthenticationResult", {}).get("IdToken")
- if not id_token:
- return False, f"no IdToken in AuthenticationResult: {auth.get('AuthenticationResult', {}).keys()}"
-
- # The ID token, not the access token: only the ID token carries
- # aud = client_id, which is what the API Gateway authorizer checks later.
- aud = _jwt_payload(id_token).get("aud")
- if aud != client_id:
- return False, f"IdToken aud={aud!r} != UserPoolClientId={client_id!r}"
- notes.append(f"IdToken aud == UserPoolClientId ({client_id})")
-
- except Exception as exc: # noqa: BLE001
- reasons = _stack_failure_reasons(cfn, _COGNITO_STACK) if created else ""
- detail = _aws_error(exc)
- if reasons:
- detail = f"{detail} || CFN: {reasons}"
- return False, detail
- finally:
- if created:
- ok_del, del_detail = _delete_stack(cfn, _COGNITO_STACK)
- notes.append(del_detail)
- if ok_del:
- # A leaked domain prefix is globally unique, so a leak makes this
- # probe non-repeatable with an error that names a name rather
- # than a cause.
- try:
- dom = idp.describe_user_pool_domain(Domain=prefix)
- released = not dom.get("DomainDescription", {}).get("UserPoolId")
- except Exception: # noqa: BLE001
- released = True
- notes.append(
- f"domain prefix {prefix} released"
- if released
- else f"domain prefix {prefix} STILL CLAIMED"
- )
-
- return True, "; ".join(notes)
-
-
-# --------------------------------------------------------------------------- #
-# The ordered probe table and the runner
-# --------------------------------------------------------------------------- #
-
-# The NINE prerequisites, cheapest-first: binaries and imports before any AWS
-# call, and the stack-deploying probe last. A machine with no credentials gets
-# its answer in milliseconds, and strict mode (Phase 5) fails fast for free.
-#
-# ``cognito_token`` is deliberately NOT in this table. It is not a prerequisite
-# any later test skips on -- it is a one-off blocking check that answers "can
-# this feature exist at all", and it deploys a user pool to do so. Keeping it out
-# means ``conftest.py`` cannot accidentally deploy a pool while deciding whether
-# to skip a test. It is run explicitly by ``main()`` and is reachable through
-# ``run_probe`` by name.
-PROBES: tuple[tuple[str, object], ...] = (
- ("credentials_resolvable", probe_credentials_resolvable),
- ("sts_caller_identity", probe_sts_caller_identity),
- ("claude_agent_sdk_importable", probe_claude_agent_sdk_importable),
- ("npm_present", probe_npm_present),
- ("uv_present", probe_uv_present),
- ("docker_daemon", probe_docker_daemon),
- ("bedrock_worker_model", probe_bedrock_worker_model),
- ("bedrock_driver_model", probe_bedrock_driver_model),
- ("iam_create_role", probe_iam_create_role),
-)
-
-BLOCKING_CHECKS: tuple[tuple[str, object], ...] = (
- ("cognito_token", probe_cognito_token),
-)
-
-
-def run_probe(name: str) -> tuple[bool, str]:
- """Run one probe by name. Used by ``conftest.py``'s single ``_unmet`` helper."""
- for probe_name, fn in PROBES + BLOCKING_CHECKS:
- if probe_name == name:
- return fn() # type: ignore[operator]
- return False, f"unknown probe {name!r}"
-
-
-def main(include_blocking: bool = True) -> int:
- """Run every probe and print one row each.
-
- Deliberately does NOT short-circuit on the first negative outcome: that would
- hide a second unmet prerequisite behind the first, turning one round of
- environment repair into several -- and nothing is saved by stopping, because
- the expensive probes are last.
- """
- print(f"IPA integration-test preflight -- region={REGION}\n")
- results: list[tuple[str, bool, str]] = []
-
- for name, fn in PROBES:
- ok, detail = fn() # type: ignore[operator]
- results.append((name, ok, detail))
- print(f" {'PASS' if ok else 'FAIL'} {name:<30} {detail}")
-
- if include_blocking:
- print("\n -- blocking check (deploys and deletes a Cognito user pool) --")
- for name, fn in BLOCKING_CHECKS:
- ok, detail = fn() # type: ignore[operator]
- results.append((name, ok, detail))
- print(f" {'PASS' if ok else 'FAIL'} {name:<30} {detail}")
-
- failed = [n for n, ok, _ in results if not ok]
- print(f"\n{len(results) - len(failed)}/{len(results)} checks passed")
- if failed:
- print(f"unmet: {', '.join(failed)}")
- # Non-zero on any negative outcome, so the command is scriptable -- while
- # still having printed every row.
- return 1 if failed else 0
-
-
-if __name__ == "__main__":
- # --probes-only skips the blocking Cognito check, so the nine-row table can
- # be printed without deploying anything.
- sys.exit(main(include_blocking="--probes-only" not in sys.argv))
diff --git a/internal/integration-tests/harness/reaper.py b/internal/integration-tests/harness/reaper.py
deleted file mode 100644
index d666584..0000000
--- a/internal/integration-tests/harness/reaper.py
+++ /dev/null
@@ -1,352 +0,0 @@
-"""Remove everything one integration-test run created.
-
-The generated aggregates are **invoked, not replaced**: ``scripts/deploy.mk``'s
-``teardown`` and ``scripts/prepare.mk``'s ``teardown-prepare`` already encode
-reverse deployment order and already wait for completion. Reimplementing that here
-would duplicate a contract this harness does not own, and would drift the day a
-tier is added.
-
-What the reaper adds is the three steps IPA's own skills deliberately refuse to
-take:
-
-1. **Empty the versioned buckets** — including every non-current version and every
- delete marker. See ``purge_versions`` for why this is the normal path here
- rather than a fallback.
-2. **Empty the ECR registry** — ``DeleteRepository`` fails on a repository holding
- images.
-3. **Delete ``{ns}-{env}-security``** — ``/ipa-destroy`` leaves it standing.
-
-The log bucket is emptied **twice**: once before the aggregates run and once again
-immediately before its own tier goes. ``MAKEFILE_TEMPLATES.md`` states it keeps
-receiving CloudFront logs until the frontend stack is torn down, so a single
-pre-pass races against delivery that continues after it.
-"""
-
-from __future__ import annotations
-
-import subprocess # nosec B404 - argv lists only, shell=False; see .ash/ash.yaml
-from dataclasses import dataclass, field
-from pathlib import Path
-
-from harness import aws
-
-#: `aws s3 rm --recursive` is the exact command the skill's own recovery guidance
-#: names, and it handles pagination and batching itself.
-_S3_RM_TIMEOUT = 900
-_ECR_TIMEOUT = 300
-#: `delete-objects` accepts at most 1000 keys per call, so the version sweep pages
-#: at that size. The page cap is a runaway guard, not a tuned figure: a log bucket
-#: that has accumulated 50k versions is a different problem from the one this
-#: module solves, and it should be reported rather than looped over silently.
-_S3_VERSION_PAGE_SIZE = 1000
-_S3_VERSION_MAX_PAGES = 50
-#: Teardown waits for stack deletion, and CloudFront distribution deletion is slow.
-#: Generous rather than tuned — an aggregate that times out leaves a half-deleted
-#: stack, which is worse than waiting.
-_MAKE_TIMEOUT = 3600
-
-
-@dataclass
-class Step:
- """One reaping action and what actually happened to it.
-
- ``ok`` records the STATE REACHED, never that a command was issued. The
- reference harness's most expensive bug was a test asserting that
- ``aws s3 rm`` *ran* — which is exactly the assumption that failed, so it
- passed against two broken runs.
- """
-
- name: str
- ok: bool
- detail: str = ""
-
-
-@dataclass
-class Report:
- steps: list[Step] = field(default_factory=list)
-
- def add(self, name: str, ok: bool, detail: str = "") -> Step:
- step = Step(name=name, ok=ok, detail=detail)
- self.steps.append(step)
- return step
-
- @property
- def ok(self) -> bool:
- return all(s.ok for s in self.steps)
-
- def render(self) -> str:
- return "\n".join(
- f" {'ok ' if s.ok else 'FAIL'} {s.name}"
- + (f" -- {s.detail}" if s.detail else "")
- for s in self.steps
- )
-
-
-def _run(argv: list[str], timeout: int, cwd: Path | None = None) -> tuple[bool, str]:
- """Run ``argv`` with ``shell=False``; never raises."""
- try:
- proc = subprocess.run( # nosec B603 B607 - argv list, shell=False
- argv,
- cwd=cwd,
- capture_output=True,
- text=True,
- timeout=timeout,
- check=False,
- )
- except FileNotFoundError:
- return False, f"{argv[0]}: not found on PATH"
- except subprocess.TimeoutExpired:
- return False, f"{argv[0]}: timed out after {timeout}s"
- except OSError as exc: # pragma: no cover - defensive
- return False, f"{argv[0]}: {exc}"
- if proc.returncode != 0:
- return False, (proc.stderr or proc.stdout or "").strip()
- return True, (proc.stdout or "").strip()
-
-
-# --------------------------------------------------------------------------- #
-# The version purge -- the step `aws s3 rm --recursive` cannot do
-# --------------------------------------------------------------------------- #
-
-
-def purge_versions(
- bucket: str,
- region: str = aws.REGION,
- runner=_run,
-) -> tuple[bool, str]:
- """Delete every object VERSION and every DELETE MARKER in ``bucket``.
-
- **This is the step ``aws s3 rm --recursive`` cannot do, and the reason this
- function exists.** On a versioned bucket ``s3 rm`` deletes only the *current*
- version of each key: it writes a delete marker and leaves the previous version
- in place. So ``aws s3 ls`` afterwards shows an empty bucket while S3 still
- holds both the non-current version and the marker — and CloudFormation's
- ``DeleteBucket`` fails with *"The bucket you tried to delete is not empty. You
- must delete all versions in the bucket."*
-
- That combination is what makes the bug expensive: the emptying step reports
- success, the listing corroborates it, and the failure surfaces minutes later
- inside a generated teardown aggregate as a stack that will not delete. Both
- buckets IPA creates come from templates that enable versioning, so this sweep
- is the **normal path** here, not a fallback.
-
- A **failed listing must not report the bucket as emptied.** That is the
- reference's most expensive class of bug in miniature: a report that a command
- ran standing in for a state having been reached.
-
- Emptying an unversioned or already-empty bucket lists nothing and deletes
- nothing, so this is a no-op rather than an error in that case.
- """
- removed = 0
- for page in range(_S3_VERSION_MAX_PAGES):
- ok, out = runner(
- [
- "aws", "s3api", "list-object-versions",
- "--bucket", bucket,
- "--max-items", str(_S3_VERSION_PAGE_SIZE),
- *aws.cli_region_args(region),
- "--output", "json",
- ],
- _S3_RM_TIMEOUT,
- )
- if not ok:
- # NOT "emptied". A listing failure is unknown state, not success.
- return False, (
- f"could not list versions after removing {removed} "
- f"(bucket may still hold versions): {out}"
- )
-
- import json # noqa: PLC0415 - kept local so the module imports without it
-
- try:
- listing = json.loads(out) if out.strip() else {}
- except json.JSONDecodeError as exc:
- return False, f"could not parse version listing: {exc}"
-
- # BOTH lists must be drained. Draining only Versions leaves the delete
- # markers, and DeleteBucket refuses on markers alone.
- objects = [
- {"Key": v["Key"], "VersionId": v["VersionId"]}
- for v in (listing.get("Versions") or [])
- ] + [
- {"Key": m["Key"], "VersionId": m["VersionId"]}
- for m in (listing.get("DeleteMarkers") or [])
- ]
- if not objects:
- return True, f"emptied ({removed} versions/markers removed)"
-
- ok, out = runner(
- [
- "aws", "s3api", "delete-objects",
- "--bucket", bucket,
- "--delete", json.dumps({"Objects": objects, "Quiet": True}),
- *aws.cli_region_args(region),
- ],
- _S3_RM_TIMEOUT,
- )
- if not ok:
- return False, f"delete-objects failed after removing {removed}: {out}"
- removed += len(objects)
-
- return False, (
- f"still not empty after {_S3_VERSION_MAX_PAGES} pages ({removed} removed); "
- "a bucket this large is a different problem -- reporting rather than looping"
- )
-
-
-def empty_bucket(bucket: str, region: str = aws.REGION, runner=_run) -> tuple[bool, str]:
- """Empty a bucket: the fast recursive delete, then the version sweep.
-
- The recursive delete runs first because it is fast and handles the common
- case. The version sweep then runs **unconditionally**, because on a versioned
- bucket the recursive delete leaves the bucket un-deletable while making it look
- empty.
- """
- rm_ok, rm_detail = runner(
- [
- "aws", "s3", "rm", f"s3://{bucket}", "--recursive",
- *aws.cli_region_args(region),
- ],
- _S3_RM_TIMEOUT,
- )
- purge_ok, purge_detail = purge_versions(bucket, region, runner=runner)
- # The purge is authoritative: it is the step that determines whether
- # DeleteBucket will succeed.
- detail = f"rm={'ok' if rm_ok else rm_detail or 'failed'}; purge={purge_detail}"
- return purge_ok, detail
-
-
-def empty_ecr(repository: str, region: str = aws.REGION, runner=_run) -> tuple[bool, str]:
- """Delete every image in an ECR repository; DeleteRepository fails otherwise."""
- ok, out = runner(
- [
- "aws", "ecr", "list-images",
- "--repository-name", repository,
- "--query", "imageIds[*]",
- *aws.cli_region_args(region),
- "--output", "json",
- ],
- _ECR_TIMEOUT,
- )
- if not ok:
- return False, f"could not list images: {out}"
-
- import json # noqa: PLC0415
-
- try:
- image_ids = json.loads(out) if out.strip() else []
- except json.JSONDecodeError as exc:
- return False, f"could not parse image listing: {exc}"
- if not image_ids:
- return True, "already empty"
-
- ok, out = runner(
- [
- "aws", "ecr", "batch-delete-image",
- "--repository-name", repository,
- "--image-ids", json.dumps(image_ids),
- *aws.cli_region_args(region),
- ],
- _ECR_TIMEOUT,
- )
- return (True, f"deleted {len(image_ids)} image(s)") if ok else (False, out)
-
-
-# --------------------------------------------------------------------------- #
-# The reaper
-# --------------------------------------------------------------------------- #
-
-
-class ReaperRefused(RuntimeError):
- """Raised when the reaper is pointed at a project it cannot describe."""
-
-
-def assert_reapable(project: Path) -> None:
- """Refuse to proceed without the generated artifacts a completed run produces.
-
- Those artifacts encode the reverse ordering and the deletion waits. Without
- them the reaper would be improvising an order against infrastructure it cannot
- describe.
- """
- deploy_mk = Path(project) / "scripts" / "deploy.mk"
- if not deploy_mk.is_file():
- raise ReaperRefused(
- f"refusing to reap: {deploy_mk} is absent, so the reverse teardown "
- "order and the deletion waits are unknown. Point the reaper at the "
- "temp project the run composed, or use sweep.py to remove stacks by "
- "namespace."
- )
-
-
-def reap(
- project: Path,
- namespace: str,
- env: str,
- region: str = aws.REGION,
- log_bucket: str | None = None,
- site_bucket: str | None = None,
- ecr_repository: str | None = None,
- runner=_run,
-) -> Report:
- """Remove one run's infrastructure. Never raises except ``ReaperRefused``."""
- assert_reapable(project)
- project = Path(project)
- report = Report()
-
- # 1. Empty the buckets and the registry BEFORE the aggregates run, because
- # the stacks that own them will not delete while they hold objects.
- # log_bucket comes from .env's LOG_BUCKET_NAME -- the reference needed a
- # name-resolution special case here and IPA does not.
- for label, bucket in (("log bucket", log_bucket), ("site bucket", site_bucket)):
- if bucket:
- ok, detail = empty_bucket(bucket, region, runner=runner)
- report.add(f"empty {label} {bucket}", ok, detail)
-
- if ecr_repository:
- ok, detail = empty_ecr(ecr_repository, region, runner=runner)
- report.add(f"empty ecr {ecr_repository}", ok, detail)
-
- # 2. The generated aggregates, which own the per-tier ordering and the waits.
- ok, detail = runner(
- ["make", "-f", "scripts/deploy.mk", "teardown"], _MAKE_TIMEOUT, project
- )
- report.add("make -f scripts/deploy.mk teardown", ok, detail if not ok else "")
-
- # 3. The log bucket AGAIN: MAKEFILE_TEMPLATES.md states it keeps receiving
- # CloudFront logs until the frontend stack is torn down, so the pre-pass
- # above raced against delivery that continued after it.
- if log_bucket:
- ok, detail = empty_bucket(log_bucket, region, runner=runner)
- report.add(f"re-empty log bucket {log_bucket}", ok, detail)
-
- ok, detail = runner(
- ["make", "-f", "scripts/prepare.mk", "teardown-prepare"],
- _MAKE_TIMEOUT,
- project,
- )
- report.add(
- "make -f scripts/prepare.mk teardown-prepare", ok, detail if not ok else ""
- )
-
- # 4. The security stack, which /ipa-destroy leaves standing.
- security_stack = f"{namespace}-{env}-security"
- ok, detail = runner(
- [
- "aws", "cloudformation", "delete-stack",
- "--stack-name", security_stack,
- *aws.cli_region_args(region),
- ],
- _MAKE_TIMEOUT,
- )
- if ok:
- ok, detail = runner(
- [
- "aws", "cloudformation", "wait", "stack-delete-complete",
- "--stack-name", security_stack,
- *aws.cli_region_args(region),
- ],
- _MAKE_TIMEOUT,
- )
- report.add(f"delete-stack {security_stack}", ok, detail if not ok else "")
-
- return report
diff --git a/internal/integration-tests/harness/standing.py b/internal/integration-tests/harness/standing.py
deleted file mode 100644
index 148edf3..0000000
--- a/internal/integration-tests/harness/standing.py
+++ /dev/null
@@ -1,266 +0,0 @@
-"""Report which of a run's resources are still live, and leave a durable pointer.
-
-``live_stacks`` returns ``(stacks, error)`` and **never raises**. It runs at session
-teardown, where an exception replaces the standing-resource report — the record of
-what is still costing money — with a fixture error. It is also what lets
-``conftest.py`` express the preservation rule without a ``try``:
-
- preserve if live_stacks(ns) is non-empty OR the listing errored
-
-The pointer is written at the **harness root** — one level above this module, beside
-the Makefile — inside the repository directory rather than into the temp project,
-because macOS may reclaim ``$TMPDIR``. And it is written to be
-readable with **no credentials configured**: its whole purpose is to survive a lost
-terminal, so a pointer needing a working credential chain to interpret fails exactly
-when it is needed.
-"""
-
-from __future__ import annotations
-
-import subprocess # nosec B404 - argv lists only, shell=False; see .ash/ash.yaml
-from pathlib import Path
-
-from harness import aws
-
-# ``parents[1]``, NOT ``parent``: this module lives in ``harness/`` but the pointer it
-# writes must land at the HARNESS ROOT. Three things already assert that location and
-# none of them fails loudly if it moves -- ``.gitignore``'s glob stops matching,
-# ``README.md``'s "beside this README" claim becomes false, and the deployed buildspec
-# collects no artifact.
-HERE = Path(__file__).resolve().parents[1]
-
-HEALTHY_STATES = frozenset(
- {"CREATE_COMPLETE", "UPDATE_COMPLETE", "UPDATE_ROLLBACK_COMPLETE"}
-)
-
-#: The seven stacks a completed IPA deployment leaves standing, in deployment order.
-#: **THE single definition** -- the deploy scenario's completion gate and its
-#: seven-stack assertions both read this one tuple. Two copies could disagree, and the
-#: disagreement is silent in both directions: a gate expecting a stack no test wants
-#: nudges a finished run, and a gate missing one lets a short deployment pass.
-EXPECTED_TIERS = ("logs", "ecr", "cognito", "security", "queue", "backend", "frontend")
-
-#: Tiers whose stacks hold NO billable resource. ``security`` is IAM only -- roles,
-#: policies and a permissions boundary, none of which is charged for.
-#:
-#: Everything else is billable or storage-billable, so the standing-resource report
-#: says BILLABLE about it. The distinction is here because a report that calls one IAM
-#: stack "BILLABLE" and demands teardown teaches a maintainer to discount the warning,
-#: which is precisely the wrong lesson on the day the standing stack is a CloudFront
-#: distribution and two Lambdas.
-FREE_TIERS = frozenset({"security"})
-
-
-def pointer_path(namespace: str) -> Path:
- """Where the pointer for ``namespace`` lives: beside the Makefile."""
- return HERE / f"standing-resources-{namespace}.md"
-
-
-def parse_env(env_file: Path) -> dict[str, str]:
- """Parse one ``.env``. Deliberately no run-folder merge.
-
- A merge across run folders means the values reported belong to no single run,
- which is the opposite of what a standing-resource report is for.
- """
- values: dict[str, str] = {}
- if not env_file.is_file():
- return values
- for raw in env_file.read_text().splitlines():
- line = raw.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, _, value = line.partition("=")
- values[key.strip()] = value.strip().strip("'\"")
- return values
-
-
-def live_stacks(
- namespace: str, region: str = aws.REGION
-) -> tuple[list[tuple[str, str]], str]:
- """Every live stack under ``namespace``, as ``(name, status)`` pairs.
-
- Returns ``(stacks, error)`` and NEVER raises. An empty list with an empty error
- means "nothing standing"; an empty list with a non-empty error means "unknown",
- and callers must treat those differently — the preservation rule preserves on
- error precisely because a listing that failed is the case where standing stacks
- are most likely.
- """
- try:
- proc = subprocess.run( # nosec B603 B607 - argv list, shell=False
- [
- "aws", "cloudformation", "list-stacks",
- *aws.cli_region_args(region),
- "--query",
- "StackSummaries[?StackStatus != 'DELETE_COMPLETE']"
- ".[StackName,StackStatus]",
- "--output", "text",
- ],
- capture_output=True,
- text=True,
- timeout=120,
- check=False,
- )
- except Exception as exc: # noqa: BLE001 - must never raise
- return [], f"{type(exc).__name__}: {exc}"
-
- if proc.returncode != 0:
- return [], (proc.stderr or proc.stdout or "").strip()
-
- stacks: list[tuple[str, str]] = []
- for line in proc.stdout.splitlines():
- parts = line.split()
- if len(parts) >= 2 and parts[0].startswith(f"{namespace}-"):
- stacks.append((parts[0], parts[1]))
- return stacks, ""
-
-
-def absent_tiers(namespace: str, env: str, found) -> list[str]:
- """Which of ``EXPECTED_TIERS`` has no stack among ``found`` stack names."""
- names = set(found)
- return [
- tier for tier in EXPECTED_TIERS if f"{namespace}-{env}-{tier}" not in names
- ]
-
-
-def billable(stacks: list[tuple[str, str]]) -> list[tuple[str, str]]:
- """The subset of ``stacks`` holding something that is charged for.
-
- A stack is judged by its tier suffix, and anything unrecognised counts as
- BILLABLE. That default is the safe direction: an unknown stack under the run's
- namespace is more likely a tier this table has not been taught than a second free
- IAM stack, and the cost of guessing wrong is a warning nobody acts on.
- """
- return [
- (name, status)
- for name, status in stacks
- if name.rsplit("-", 1)[-1] not in FREE_TIERS
- ]
-
-
-def render_pointer(
- namespace: str,
- env: str,
- region: str,
- credentials: str,
- stacks: list[tuple[str, str]],
- env_values: dict[str, str] | None = None,
-) -> str:
- """The pointer's text. Readable with no credentials configured."""
- env_values = env_values or {}
- charged = billable(stacks)
- if charged:
- headline = (
- "**These resources are BILLABLE and are still deployed.** A passing "
- "integration-test run leaves them standing on purpose, so a maintainer "
- "can open the application in a browser and confirm by eye what the "
- "assertions claim."
- )
- else:
- headline = (
- "**Nothing standing here is billable** — every stack below is IAM only. "
- "It is still worth removing, because the namespace must be free for the "
- "next run and because a half-finished run is what usually leaves exactly "
- "this: the free stack and nothing else."
- )
- lines = [
- f"# Standing resources — run namespace `{namespace}`",
- "",
- headline,
- "",
- "## Remove them",
- "",
- "```bash",
- f"cd {HERE}",
- f"make teardown-namespace NAMESPACE={namespace}",
- "```",
- "",
- f"- namespace: `{namespace}`",
- f"- environment: `{env}`",
- f"- region: `{region}`",
- f"- credentials: `{credentials}`",
- "",
- ]
- if stacks:
- free = {name for name, _ in stacks} - {name for name, _ in charged}
- lines += ["## Stacks", ""]
- for name, status in stacks:
- note = " (IAM only, not billable)" if name in free else ""
- lines.append(f"- `{name}` — {status}{note}")
- lines.append("")
- for key in ("APP_URL", "API_URL", "LOG_BUCKET_NAME", "ECR_REPOSITORY_NAME"):
- if env_values.get(key):
- lines.append(f"- {key}: `{env_values[key]}`")
- if any(env_values.get(k) for k in ("APP_URL", "API_URL")):
- lines.append("")
- return "\n".join(lines) + "\n"
-
-
-def write_report(
- namespace: str,
- env: str,
- region: str,
- credentials: str,
- stacks: list[tuple[str, str]],
- env_values: dict[str, str] | None = None,
- redact=None,
-) -> Path:
- """Write the pointer to a file. FILE FIRST -- see ``report_everywhere``."""
- text = render_pointer(namespace, env, region, credentials, stacks, env_values)
- if redact is not None:
- text = redact(text)
- path = pointer_path(namespace)
- path.write_text(text)
- return path
-
-
-def report_everywhere(
- namespace: str,
- env: str,
- region: str,
- credentials: str,
- stacks: list[tuple[str, str]],
- env_values: dict[str, str] | None = None,
- redact=None,
-) -> Path:
- """Emit the standing-resource report through all three channels, file first.
-
- Each channel fails differently: a printed report is lost with the terminal, and
- a warning can be suppressed by the test runner's configuration. The file
- survives both, so it is written FIRST — a crash between channels then loses the
- weaker channels rather than the strongest.
- """
- import warnings # noqa: PLC0415
-
- path = write_report(
- namespace, env, region, credentials, stacks, env_values, redact=redact
- )
- charged = billable(stacks)
- # Counted, not asserted: "3 stack(s), 2 of them billable" is actionable where a
- # flat "3 BILLABLE stack(s)" over one IAM stack trains the reader to ignore it.
- scale = (
- f"{len(stacks)} stack(s) left standing under namespace {namespace!r}, "
- f"{len(charged)} of them billable"
- if charged
- else f"{len(stacks)} stack(s) left standing under namespace {namespace!r}, "
- "none of them billable (IAM only)"
- )
- message = (
- f"{scale}. Remove with: make teardown-namespace NAMESPACE={namespace} "
- f"(details: {path})"
- )
- print(f"\n{message}\n")
- warnings.warn(message, stacklevel=2)
- return path
-
-
-def remove_pointer(namespace: str) -> bool:
- """Remove a namespace's pointer. Returns whether a file was removed.
-
- Callers must call this ONLY for a namespace whose resources were CONFIRMED
- deleted (reference F5).
- """
- path = pointer_path(namespace)
- if path.is_file():
- path.unlink()
- return True
- return False
diff --git a/internal/integration-tests/harness/sweep.py b/internal/integration-tests/harness/sweep.py
deleted file mode 100644
index 87eef0d..0000000
--- a/internal/integration-tests/harness/sweep.py
+++ /dev/null
@@ -1,249 +0,0 @@
-"""Find and remove integration-test stacks across runs, scoped by namespace.
-
-Namespaces are unique per run, so a forgotten run leaves billable infrastructure
-that nothing but a prefix sweep can find. That is why this module exists even
-though only one test needs it.
-
-Two properties are the dangerous ones, and both are designed to be provable
-offline:
-
-* **``select_namespaces`` is a pure function** over an already-grouped listing,
- not a narrowing of the prefix passed to the API. That is what makes "what gets
- deleted" testable without deleting anything.
-* **Nothing may tie with the ``logs`` rank.** Python's sort is stable, so two
- entries sharing a rank keep input order — and the log bucket would then delete
- before a dependent tier on some runs and after it on others, depending on how
- the stack listing happened to come back.
-"""
-
-from __future__ import annotations
-
-import os
-import subprocess # nosec B404 - argv lists only, shell=False; see .ash/ash.yaml
-import sys
-from pathlib import Path
-
-from harness import aws, standing
-
-#: Teardown order for IPA's tiers. `logs` is deliberately ABSENT -- see below.
-TIER_ORDER = ("frontend", "backend", "queue", "cognito", "ecr", "security")
-
-#: Ranks for the two cases outside TIER_ORDER.
-#:
-#: An unknown tier sorts after every known one but strictly BEFORE `logs`, so a
-#: tier added later is removed before the log bucket.
-#:
-#: These MUST NOT collide. Appending "logs" to TIER_ORDER is the tempting form and
-#: is exactly what makes an unknown tier tie with it: Python's sort is stable, so a
-#: tie keeps input order, which for `logs` means the log bucket sometimes going
-#: first depending on how the stack listing came back. MAKEFILE_TEMPLATES.md states
-#: the log bucket keeps receiving CloudFront logs until the frontend stack is torn
-#: down, so removing it earlier deletes a bucket that is still being written to.
-UNKNOWN_RANK = len(TIER_ORDER)
-LOGS_RANK = len(TIER_ORDER) + 1
-
-
-def tier_of(stack_name: str) -> str:
- """The tier suffix of a stack name, or the trailing segment if unrecognised."""
- for suffix in (*TIER_ORDER, "logs"):
- if stack_name.endswith(f"-{suffix}"):
- return suffix
- return stack_name.rsplit("-", 1)[-1] if "-" in stack_name else stack_name
-
-
-def tier_rank(stack_name: str) -> int:
- """Sort key placing a stack in the documented teardown order."""
- if stack_name.endswith("-logs"):
- return LOGS_RANK
- for index, suffix in enumerate(TIER_ORDER):
- if stack_name.endswith(f"-{suffix}"):
- return index
- return UNKNOWN_RANK
-
-
-def teardown_order(stacks: list[str]) -> list[str]:
- """Stacks in the order they must be removed."""
- return sorted(stacks, key=tier_rank)
-
-
-def select_namespaces(
- grouped: dict[str, list[str]], only: str | None
-) -> dict[str, list[str]]:
- """Narrow a grouped stack listing to one namespace, or pass it through.
-
- A **pure function** over the ALREADY-GROUPED listing rather than a narrowing of
- the prefix passed to the API. Two reasons, both load-bearing:
-
- 1. It makes the most dangerous behaviour in the system — what gets deleted —
- testable with no credentials and nothing deleted.
- 2. An unmatched namespace selects **nothing**, and a caller can tell the
- difference between "nothing matched" and "everything matched", which a
- prefix narrowed at the API cannot express.
-
- A missing or empty ``only`` passes the listing through unchanged. Callers that
- delete MUST refuse an empty namespace before reaching here — see ``main``.
- """
- if not only:
- return dict(grouped)
- return {ns: stacks for ns, stacks in grouped.items() if ns == only}
-
-
-def _run(argv: list[str], timeout: int = 300) -> tuple[bool, str]:
- try:
- proc = subprocess.run( # nosec B603 B607 - argv list, shell=False
- argv, capture_output=True, text=True, timeout=timeout, check=False
- )
- except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
- return False, str(exc)
- if proc.returncode != 0:
- return False, (proc.stderr or proc.stdout or "").strip()
- return True, (proc.stdout or "").strip()
-
-
-def group_by_namespace(stack_names: list[str], env: str = "dev") -> dict[str, list[str]]:
- """Group stack names by the namespace segment of ``{ns}-{env}-{tier}``."""
- grouped: dict[str, list[str]] = {}
- marker = f"-{env}-"
- for name in stack_names:
- if marker not in name:
- continue
- ns = name.split(marker, 1)[0]
- grouped.setdefault(ns, []).append(name)
- return grouped
-
-
-def list_it_stacks(region: str = aws.REGION, prefix: str = "it") -> tuple[list[str], str]:
- """Every live stack whose name starts with the harness's namespace prefix."""
- ok, out = _run(
- [
- "aws", "cloudformation", "list-stacks",
- *aws.cli_region_args(region),
- "--query",
- "StackSummaries[?StackStatus != 'DELETE_COMPLETE'].StackName",
- "--output", "text",
- ]
- )
- if not ok:
- return [], out
- names = [n for n in out.split() if n.startswith(prefix)]
- return names, ""
-
-
-# --------------------------------------------------------------------------- #
-# CLI
-# --------------------------------------------------------------------------- #
-
-
-def cmd_list(region: str = aws.REGION) -> int:
- names, error = list_it_stacks(region)
- if error:
- print(f"could not list stacks: {error}", file=sys.stderr)
- return 1
- grouped = group_by_namespace(names)
- if not grouped:
- print("No integration-test stacks found.")
- return 0
- for ns, stacks in sorted(grouped.items()):
- print(f"\n {ns}:")
- for name in teardown_order(stacks):
- print(f" - {name}")
- total = sum(len(v) for v in grouped.values())
- print(f"\n{total} stack(s) across {len(grouped)} run(s). These are BILLABLE.")
- print("Remove one run with: make teardown-namespace NAMESPACE=")
- return 0
-
-
-def cmd_teardown(region: str, namespace: str | None) -> int:
- # Opt-in one of two. The other is the `teardown` pytest marker.
- if os.environ.get("IPA_IT_TEARDOWN") != "1":
- print(
- "Refusing to delete anything: IPA_IT_TEARDOWN=1 is not set.\n"
- "Teardown requires TWO independent opt-ins, because one is satisfiable "
- "by accident -- a pipeline-level variable applies to every phase of "
- "every build.",
- file=sys.stderr,
- )
- return 2
-
- # An empty namespace is NEVER "every run". That is the unbounded failure mode:
- # it would delete other runs' infrastructure, and possibly infrastructure that
- # is not a test run at all.
- if not namespace:
- print(
- "Refusing to delete anything: no NAMESPACE given. An empty namespace is "
- "not treated as 'every run'.",
- file=sys.stderr,
- )
- return 2
-
- names, error = list_it_stacks(region)
- if error:
- print(f"could not list stacks: {error}", file=sys.stderr)
- return 1
-
- selected = select_namespaces(group_by_namespace(names), namespace)
- if not selected:
- # "Nothing matched" is reported as such, not as success over an empty set.
- print(
- f"No stacks found under namespace {namespace!r}. Nothing was deleted.",
- file=sys.stderr,
- )
- return 1
-
- all_confirmed = True
- for ns, stacks in selected.items():
- print(f"\nTearing down {ns} ({len(stacks)} stack(s))")
- confirmed = True
- for name in teardown_order(stacks):
- ok, detail = _run(
- [
- "aws", "cloudformation", "delete-stack",
- "--stack-name", name,
- *aws.cli_region_args(region),
- ]
- )
- if ok:
- ok, detail = _run(
- [
- "aws", "cloudformation", "wait", "stack-delete-complete",
- "--stack-name", name,
- *aws.cli_region_args(region),
- ],
- timeout=3600,
- )
- print(f" {'ok ' if ok else 'FAIL'} {name}" + ("" if ok else f" -- {detail}"))
- confirmed = confirmed and ok
-
- # The pointer is removed ONLY for a namespace whose stacks were CONFIRMED
- # deleted (reference F5). A pointer surviving a successful teardown errs
- # safe but teaches a reader to distrust the next pointer, which may be
- # true; a pointer removed after a FAILED teardown loses the only record of
- # what is still costing money.
- if confirmed:
- removed = standing.remove_pointer(ns)
- print(f" {'removed' if removed else 'no'} standing-resources pointer for {ns}")
- else:
- print(
- f" KEEPING standing-resources pointer for {ns}: teardown was partial"
- )
- all_confirmed = all_confirmed and confirmed
-
- return 0 if all_confirmed else 1
-
-
-def main(argv: list[str] | None = None) -> int:
- argv = list(sys.argv[1:] if argv is None else argv)
- command = argv[0] if argv else "list"
- region = aws.REGION
- namespace = os.environ.get("NAMESPACE") or (argv[1] if len(argv) > 1 else None)
-
- if command == "list":
- return cmd_list(region)
- if command == "teardown":
- return cmd_teardown(region, namespace)
- print(f"unknown command {command!r}; expected 'list' or 'teardown'", file=sys.stderr)
- return 2
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/internal/integration-tests/harness/tests/pytest.ini b/internal/integration-tests/harness/tests/pytest.ini
deleted file mode 100644
index 906eab6..0000000
--- a/internal/integration-tests/harness/tests/pytest.ini
+++ /dev/null
@@ -1,16 +0,0 @@
-[pytest]
-testpaths = .
-
-# NO markers are registered, and none are used. This directory is the free regression
-# suite for the shared harness modules: no credentials, no AWS call, nothing created.
-# The DIRECTORY is what separates free from billable now, which is why these modules
-# no longer need a marker or a name prefix to say so.
-
-# --strict-markers so a marker typo'd into a module here is a hard error rather than a
-# warning -- an unregistered marker elsewhere is a PytestUnknownMarkWarning that is
-# easy to miss.
-#
-# --import-mode=importlib because this tree holds more than one test directory. Under
-# pytest's default `prepend` mode, two directories each holding a same-named module
-# collide and one silently shadows the other, with no error at collection.
-addopts = --strict-markers --import-mode=importlib
diff --git a/internal/integration-tests/harness/tests/test_answers.py b/internal/integration-tests/harness/tests/test_answers.py
deleted file mode 100644
index f555d41..0000000
--- a/internal/integration-tests/harness/tests/test_answers.py
+++ /dev/null
@@ -1,232 +0,0 @@
-"""Offline tests for the answer resolver and namespace generation."""
-
-from __future__ import annotations
-
-import pytest
-
-from harness import answers
-from harness.answers import AnswerResolver, UnknownQuestion, make_namespace
-
-NAMESPACE_QUESTION = (
- "Choose a project namespace. All CloudFormation stacks start with "
- "`{namespace}-{env}-`. Must be unique per account+environment. "
- "(1-12 chars, lowercase letters/digits/hyphens, starts with letter)"
-)
-
-
-@pytest.fixture
-def resolver() -> AnswerResolver:
- return AnswerResolver(namespace="itcafe01", profile="my-profile")
-
-
-# --------------------------------------------------------------------------- #
-# The resolver refuses rather than guesses
-# --------------------------------------------------------------------------- #
-
-
-def test_unknown_question_raises(resolver: AnswerResolver):
- """/ipa-init batches five questions. Guessing one writes a subtly wrong .env
- that still passes the init gate, so the run continues and fails somewhere that
- does not mention the field."""
- with pytest.raises(UnknownQuestion, match="no pinned answer"):
- resolver.resolve_one("What is your favourite colour?", ["blue", "red"])
-
-
-def test_unknown_question_names_the_question(resolver: AnswerResolver):
- with pytest.raises(UnknownQuestion, match="favourite colour"):
- resolver.resolve_one("What is your favourite colour?", [])
-
-
-def test_a_batch_with_one_unknown_question_yields_no_partial_map(
- resolver: AnswerResolver,
-):
- batch = [
- {"question": "Which environment?", "options": [{"label": "dev"}]},
- {"question": "Totally unknown?", "options": [{"label": "x"}]},
- ]
- with pytest.raises(UnknownQuestion):
- resolver.resolve(batch)
-
-
-def test_a_fully_known_batch_resolves_every_question(resolver: AnswerResolver):
- batch = [
- {"question": "Which AWS region for deployments?", "options": []},
- {"question": "Which environment?", "options": []},
- {"question": "Which infrastructure-as-code tool?", "options": []},
- ]
- resolved = resolver.resolve(batch)
- assert resolved["Which AWS region for deployments?"] == "us-east-1"
- assert resolved["Which environment?"] == "dev"
- assert resolved["Which infrastructure-as-code tool?"] == "cloudformation"
-
-
-# --------------------------------------------------------------------------- #
-# Matching
-# --------------------------------------------------------------------------- #
-
-
-def test_exact_text_wins_over_the_option_fallback(resolver: AnswerResolver):
- """Exact text is consulted first, so a prose edit surfaces as a failure rather
- than as a silently different answer."""
- got = resolver.resolve_one(
- "Which environment?", ["cloudformation", "terraform"]
- )
- assert got == "dev"
-
-
-def test_option_set_fallback_resolves_a_reworded_question(resolver: AnswerResolver):
- got = resolver.resolve_one(
- "Which IaC engine would you like?", ["cloudformation", "terraform"]
- )
- assert got == "cloudformation"
-
-
-def test_recommended_suffix_is_stripped_when_matching_options(resolver: AnswerResolver):
- got = resolver.resolve_one(
- "Reworded region question?",
- ["us-east-1 (Recommended)", "us-west-2", "eu-west-1"],
- )
- assert got == "us-east-1"
-
-
-def test_the_two_managed_policy_questions_stay_separate_entries():
- """/ipa-compose says 'Managed Policy ARN'; /ipa-security says 'Managed policy'.
-
- They occupy the same role and differ only in wording. A future prose edit
- aligning them must not collapse them, or one skill silently receives the
- other's answer.
- """
- compose_q = "How should IPA configure security infrastructure?"
- security_q = "How should IPA configure IAM execution roles?"
-
- assert compose_q in answers.ANSWERS
- assert security_q in answers.ANSWERS
- assert answers.ANSWERS[compose_q] is not answers.ANSWERS[security_q]
- assert answers.ANSWERS[compose_q].value == "Managed Policy ARN"
- assert answers.ANSWERS[security_q].value == "Managed policy"
-
-
-def test_there_are_exactly_nine_pinned_answers():
- assert len(answers.ANSWERS) == 9
-
-
-# --------------------------------------------------------------------------- #
-# Free text carries the text, never the placeholder
-# --------------------------------------------------------------------------- #
-
-
-def test_namespace_answer_is_the_generated_value_not_the_placeholder(
- resolver: AnswerResolver,
-):
- got = resolver.resolve_one(NAMESPACE_QUESTION, ["app (Recommended)"])
- assert got == "itcafe01"
- assert got != "Other"
- assert got != "app"
-
-
-def test_profile_answer_is_a_real_profile_name_not_skip(resolver: AnswerResolver):
- """'Skip' is the RECOMMENDED option and omits AWS_PROFILE from .env entirely,
- while three downstream skills require it."""
- got = resolver.resolve_one(
- "Which AWS CLI profile should IPA use?", ["Skip (Recommended)", "default"]
- )
- assert got == "my-profile"
- assert got not in ("Skip", "Other")
-
-
-def test_managed_policy_answer_is_readonlyaccess(resolver: AnswerResolver):
- """HITL default K1.A, matching Phase 1's probe constant."""
- got = resolver.resolve_one(
- "Which managed policy should IPA attach to both execution roles?",
- ["PowerUserAccess (Recommended)", "ReadOnlyAccess"],
- )
- assert got == "ReadOnlyAccess"
- assert got == answers.MANAGED_POLICY_ANSWER
-
-
-# --------------------------------------------------------------------------- #
-# Namespace rules, asserted at the point of generation
-# --------------------------------------------------------------------------- #
-
-
-def test_a_generated_namespace_satisfies_every_rule():
- for _ in range(50):
- ns = make_namespace()
- assert answers.NS_RE.match(ns)
- assert not ns.startswith("-") and not ns.endswith("-") and "--" not in ns
- assert not any(bad in ns for bad in answers.NS_FORBIDDEN)
-
-
-#: Ten malformed values, each failing at its source. An invalid namespace otherwise
-#: surfaces one deployment later as a Cognito domain-prefix error that never
-#: mentions the namespace.
-MALFORMED = [
- ("thirteenchars", "max is 12"),
- ("", "empty"),
- ("1leading", r"violates"),
- ("-leading", "must not start with a hyphen"),
- ("trailing-", "must not end with a hyphen"),
- ("has--double", "consecutive hyphens"),
- ("HasUpper", "violates"),
- ("has_under", "violates"),
- ("mycognito", "cognito"),
- ("myaws", "aws"),
- ("amazonrun", "amazon"),
-]
-
-
-@pytest.mark.parametrize("value,expected", MALFORMED)
-def test_malformed_namespace_fails_at_generation(value: str, expected: str):
- with pytest.raises(ValueError, match=expected):
- make_namespace(value)
-
-
-def test_there_are_at_least_ten_malformed_cases():
- assert len(MALFORMED) >= 10
-
-
-def test_an_injected_namespace_is_validated_by_the_resolver():
- """The resolver passes an injected value THROUGH make_namespace, not around it.
-
- ``namespace or make_namespace()`` would never check an injected value, and a
- malformed one fails much later as a domain-prefix error naming nothing relevant.
- """
- with pytest.raises(ValueError, match="cognito"):
- AnswerResolver(namespace="mycognito", profile="p")
-
-
-def test_a_valid_injected_namespace_reaches_the_resolver():
- r = AnswerResolver(namespace="itbeef99", profile="p")
- assert r.namespace == "itbeef99"
- assert r.resolve_one(NAMESPACE_QUESTION, ["app"]) == "itbeef99"
-
-
-# --------------------------------------------------------------------------- #
-# The rules have ONE home
-# --------------------------------------------------------------------------- #
-
-
-def test_namespace_rules_are_imported_from_answers_not_restated():
- """A restated rule drifts invisibly in the direction that matters: a test
- carrying its own copy of the pattern passes against that copy while the
- generator uses another.
-
- Scoped to the harness's own modules -- it cannot detect a copy in code it does
- not inspect, which is why it is named for what it covers.
- """
- import re as _re
- from pathlib import Path as _Path
-
- here = _Path(__file__).resolve().parent
- # The literal pattern and the forbidden-substring tuple may appear ONLY in
- # answers.py.
- offenders: list[str] = []
- for py in sorted(here.glob("*.py")):
- if py.name == "answers.py":
- continue
- text = py.read_text()
- if _re.search(r"\[a-z\]\[a-z0-9-\]\{0,11\}", text):
- offenders.append(f"{py.name}: restates NS_PATTERN")
- if _re.search(r"[\"']cognito[\"']\s*,\s*[\"']aws[\"']", text):
- offenders.append(f"{py.name}: restates NS_FORBIDDEN")
- assert not offenders, offenders
diff --git a/internal/integration-tests/harness/tests/test_artifacts.py b/internal/integration-tests/harness/tests/test_artifacts.py
deleted file mode 100644
index 0f03dde..0000000
--- a/internal/integration-tests/harness/tests/test_artifacts.py
+++ /dev/null
@@ -1,233 +0,0 @@
-"""Offline tests for the ``scripts/*.mk`` parsers, against fixture Makefiles.
-
-This module is why ``artifacts.py`` is a separate module rather than folded into
-``test_compose.py``: the composition assertions are only testable offline if their
-parsers can be pointed at a fixture.
-
-Each fixture includes a NEGATIVE case, so a parser is shown to *detect* the defect
-rather than merely to pass on a healthy input. A parser that returned ``True`` for
-everything would pass a positive-only suite.
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import pytest
-
-from harness import artifacts
-
-GOOD_DEPLOY_MK = """\
-# Generated by /ipa-compose
-# stacks: frontend backend queue
-# engine: cloudformation
-
-.PHONY: deploy teardown deploy-frontend deploy-backend deploy-queue
-
-deploy: deploy-queue deploy-backend deploy-frontend
-
-teardown: teardown-frontend teardown-backend teardown-queue
-
-deploy-queue:
-\taws cloudformation deploy --stack-name $(NS)-$(ENV)-queue \\
-\t\t--parameter-overrides Namespace=$(NS) Environment=$(ENV)
-
-deploy-backend:
-\taws cloudformation deploy --stack-name $(NS)-$(ENV)-backend \\
-\t\t--parameter-overrides Namespace=$(NS) EnableSqsIntegration=true \\
-\t\t\tSqsQueueUrl=$(QUEUE_URL)
-
-deploy-frontend:
-\taws cloudformation deploy --stack-name $(NS)-$(ENV)-frontend
-"""
-
-BAD_ORDER_DEPLOY_MK = """\
-# stacks: frontend backend queue
-
-deploy: deploy-queue deploy-backend deploy-frontend
-
-teardown: teardown-queue teardown-backend teardown-frontend
-"""
-
-GOOD_BUILD_MK = """\
-.PHONY: build build-web build-rest-lambda
-
-build: build-web build-rest-lambda
-
-build-web:
-\tcd web-client && npm ci && npm run build
-
-build-rest-lambda:
-\tcd app-lib && docker build -t rest-lambda .
-"""
-
-BAD_BUILD_MK = """\
-build-web:
-\tcd frontend && npm ci && npm run build
-
-build-rest-lambda:
-\tdocker build -t a .
-
-build-rest-lambda:
-\tdocker build -t b .
-"""
-
-GOOD_ENV_MK = """\
-.PHONY: update-env update-env-backend update-env-frontend
-
-update-env: update-env-backend update-env-frontend
-
-update-env-backend:
-\t@echo API_URL=...
-
-update-env-frontend:
-\t@echo APP_URL=...
-"""
-
-
-# --------------------------------------------------------------------------- #
-# Artifact presence
-# --------------------------------------------------------------------------- #
-
-
-def test_there_are_eight_expected_artifacts():
- assert len(artifacts.EXPECTED_ARTIFACTS) == 8
-
-
-def test_missing_artifacts_names_every_absent_one(tmp_path: Path):
- (tmp_path / "scripts").mkdir()
- (tmp_path / "scripts" / "deploy.mk").write_text(GOOD_DEPLOY_MK)
-
- missing = artifacts.missing_artifacts(tmp_path)
-
- assert "scripts/deploy.mk" not in missing
- assert "scripts/env.mk" in missing
- assert len(missing) == 7
-
-
-def test_missing_artifacts_is_empty_for_a_complete_composition(tmp_path: Path):
- (tmp_path / "scripts").mkdir()
- for rel in artifacts.EXPECTED_ARTIFACTS:
- (tmp_path / rel).write_text("x\n")
- assert artifacts.missing_artifacts(tmp_path) == []
-
-
-# --------------------------------------------------------------------------- #
-# Targets and prerequisites
-# --------------------------------------------------------------------------- #
-
-
-def test_targets_excludes_phony_and_recipe_lines():
- found = artifacts.targets(GOOD_DEPLOY_MK)
- assert ".PHONY" not in found
- assert "deploy" in found and "deploy-backend" in found
-
-
-def test_prerequisites_returns_them_in_order():
- assert artifacts.prerequisites(GOOD_DEPLOY_MK, "deploy") == [
- "deploy-queue", "deploy-backend", "deploy-frontend",
- ]
-
-
-def test_prerequisites_of_an_absent_target_is_empty():
- assert artifacts.prerequisites(GOOD_DEPLOY_MK, "nope") == []
-
-
-def test_target_count_detects_a_duplicate():
- """A duplicated container-build target either builds twice or builds the wrong
- one depending on ordering."""
- assert artifacts.target_count(GOOD_BUILD_MK, "build-rest-lambda") == 1
- assert artifacts.target_count(BAD_BUILD_MK, "build-rest-lambda") == 2
-
-
-def test_target_body_captures_only_that_target_s_recipe():
- body = artifacts.target_body(GOOD_BUILD_MK, "build-web")
- assert "web-client" in body
- assert "docker build" not in body
-
-
-# --------------------------------------------------------------------------- #
-# Reverse ordering -- with a negative fixture
-# --------------------------------------------------------------------------- #
-
-
-def test_teardown_is_recognised_as_the_reverse_of_deploy():
- """The two prerequisite lists never share a name: MAKEFILE_TEMPLATES.md:54
- generates ``deploy-{suffix}`` and :162 generates ``teardown-{suffix}``. So the
- comparison is on tier suffixes, and comparing raw names would report every
- healthy artifact as misordered.
- """
- ok, dep, tear = artifacts.teardown_is_reverse_of_deploy(GOOD_DEPLOY_MK)
- assert ok
- assert artifacts._suffixes(tear, "teardown-") == list(
- reversed(artifacts._suffixes(dep, "deploy-"))
- )
- assert tear != list(reversed(dep)), "raw names must NOT be expected to match"
-
-
-def test_a_teardown_in_the_same_order_as_deploy_is_detected():
- """The parser must DETECT the defect, not merely pass on a healthy input."""
- ok, dep, tear = artifacts.teardown_is_reverse_of_deploy(BAD_ORDER_DEPLOY_MK)
- assert ok is False
- assert dep and tear
-
-
-def test_a_missing_deploy_target_is_not_reported_as_correctly_reversed():
- ok, _, _ = artifacts.teardown_is_reverse_of_deploy("teardown:\n\t@true\n")
- assert ok is False
-
-
-# --------------------------------------------------------------------------- #
-# Parameter overrides -- the only proof the cross-tier wiring fired
-# --------------------------------------------------------------------------- #
-
-
-def test_parameter_overrides_finds_the_sqs_integration_flag():
- params = artifacts.parameter_overrides(GOOD_DEPLOY_MK, "deploy-backend")
- assert params.get("EnableSqsIntegration") == "true"
-
-
-def test_parameter_overrides_absent_flag_is_detected():
- params = artifacts.parameter_overrides(GOOD_DEPLOY_MK, "deploy-queue")
- assert "EnableSqsIntegration" not in params
-
-
-def test_parameter_overrides_of_a_target_without_any_is_empty():
- assert artifacts.parameter_overrides(GOOD_BUILD_MK, "build-web") == {}
-
-
-# --------------------------------------------------------------------------- #
-# The header tier set
-# --------------------------------------------------------------------------- #
-
-
-def test_header_tiers_reads_the_generated_header():
- assert artifacts.header_tiers(GOOD_DEPLOY_MK) == {"frontend", "backend", "queue"}
-
-
-def test_header_tiers_falls_back_to_deploy_targets_when_no_header_line():
- """A header-format change must degrade to a weaker check, never to a false pass."""
- no_header = GOOD_DEPLOY_MK.split("\n", 4)[4]
- assert artifacts.header_tiers(no_header) == {"frontend", "backend", "queue"}
-
-
-# --------------------------------------------------------------------------- #
-# The build and env artifacts, with negative fixtures
-# --------------------------------------------------------------------------- #
-
-
-def test_build_mk_frontend_directory_defect_is_detectable():
- """The template may emit ``cd frontend``, which does not exist in this
- repository -- failing twenty minutes into a build with an error about a missing
- directory."""
- assert "cd web-client" in GOOD_BUILD_MK
- assert "cd web-client" not in BAD_BUILD_MK
- assert "cd frontend" in BAD_BUILD_MK
-
-
-@pytest.mark.parametrize("target", ["update-env-backend", "update-env-frontend"])
-def test_env_mk_sync_targets_are_detectable(target: str):
- """Their absence is fully silent: the deployment succeeds and the frontend is
- served a config naming no API and no identity provider."""
- assert target in artifacts.targets(GOOD_ENV_MK)
- assert target not in artifacts.targets(GOOD_BUILD_MK)
diff --git a/internal/integration-tests/harness/tests/test_nudge.py b/internal/integration-tests/harness/tests/test_nudge.py
deleted file mode 100644
index 486fde3..0000000
--- a/internal/integration-tests/harness/tests/test_nudge.py
+++ /dev/null
@@ -1,337 +0,0 @@
-"""The nudge loop, and the billable/free classification of a standing stack.
-
-Both behaviours exist because of one measured failure (2026-08-18): a drive ended its
-turn a tenth of the way through ``/ipa-compose``, reported
-``ResultMessage(subtype='success')``, and left exactly one free IAM stack standing.
-Twenty-one assertions then failed on ``describe_stacks`` for stacks nothing created,
-and the standing-resource report called the one IAM stack BILLABLE.
-
-These tests are free and offline: the SDK client is replaced with a fake, so the loop's
-control flow -- how many times it re-queries, when it refuses to -- is checked without a
-session, a credential or a cent.
-"""
-
-from __future__ import annotations
-
-import asyncio
-from dataclasses import dataclass
-
-import pytest
-
-from harness import answers, artifacts, driver, lifecycle, standing
-
-
-# --------------------------------------------------------------------------- #
-# A fake SDK client
-# --------------------------------------------------------------------------- #
-
-
-@dataclass
-class _FakeResult:
- """Stands in for ``ResultMessage``. Matched by CLASS NAME in ``drive``."""
-
- subtype: str = "success"
- total_cost_usd: float = 0.5
- num_turns: int = 7
-
-
-# ``drive`` dispatches on ``type(message).__name__``, so the fake must carry the real
-# name. Renaming rather than subclassing keeps the SDK out of this test entirely --
-# which is the point: the offline tier must run on a checkout with no extras installed.
-_FakeResult.__name__ = "ResultMessage"
-
-
-class _FakeClient:
- """Records every prompt it is queried with; yields one result per query."""
-
- def __init__(self, options=None) -> None:
- self.options = options
- self.prompts: list[str] = []
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, *exc) -> bool:
- return False
-
- async def query(self, prompt: str) -> None:
- self.prompts.append(prompt)
-
- async def receive_response(self):
- yield _FakeResult()
-
-
-@pytest.fixture
-def fake_client(monkeypatch):
- """Install one fake client and hand it back so its prompts can be inspected."""
- created: list[_FakeClient] = []
-
- def factory(options=None):
- client = _FakeClient(options)
- created.append(client)
- return client
-
- monkeypatch.setattr(driver, "ClaudeSDKClient", factory)
- return created
-
-
-@pytest.fixture
-def transcript(tmp_path):
- return driver.Transcript(path=tmp_path / "t.jsonl")
-
-
-@pytest.fixture
-def resolver():
- return answers.AnswerResolver(namespace="itaaaaaaaa", profile="p")
-
-
-def _drive(transcript, resolver, tmp_path, **kwargs):
- """Run ``drive`` with options already built, so no SDK is needed."""
- return asyncio.run(
- driver.drive(
- "go", tmp_path, resolver, transcript, options=_FakeOptions(), **kwargs
- )
- )
-
-
-@dataclass
-class _FakeOptions:
- """Only the two fields ``drive`` records at ``run_start``."""
-
- max_budget_usd: float = 5.0
- max_turns: int = 300
-
-
-# --------------------------------------------------------------------------- #
-# The loop
-# --------------------------------------------------------------------------- #
-
-
-def test_a_complete_drive_is_never_nudged(fake_client, transcript, resolver, tmp_path):
- result = _drive(
- transcript, resolver, tmp_path, is_complete=lambda _r: (True, "")
- )
- assert result.nudges == 0
- assert result.incomplete_reason == ""
- assert fake_client[0].prompts == ["go"]
-
-
-def test_no_predicate_means_no_nudge(fake_client, transcript, resolver, tmp_path):
- """Absent a definition of "finished", the drive must not invent one.
-
- Nudging by default would re-query every scenario that has not opted in, and each
- re-query costs money on the billable path.
- """
- result = _drive(transcript, resolver, tmp_path)
- assert result.nudges == 0
- assert fake_client[0].prompts == ["go"]
-
-
-def test_an_unfinished_drive_is_nudged_up_to_the_cap(
- fake_client, transcript, resolver, tmp_path
-):
- result = _drive(
- transcript,
- resolver,
- tmp_path,
- is_complete=lambda _r: (False, "scripts/deploy.mk is absent"),
- max_nudges=2,
- )
- assert result.nudges == 2
- # One original prompt plus one per nudge -- the cap bounds the spend.
- assert len(fake_client[0].prompts) == 3
- assert "scripts/deploy.mk is absent" in fake_client[0].prompts[1]
- assert result.incomplete_reason == "scripts/deploy.mk is absent"
-
-
-def test_the_nudge_states_what_is_missing_not_merely_continue(
- fake_client, transcript, resolver, tmp_path
-):
- """The agent has just reported success; it needs the specific gap.
-
- A bare "continue" invites it to re-run the step it thinks it finished, which on the
- deploy path means a second composition over a half-composed project.
- """
- _drive(
- transcript,
- resolver,
- tmp_path,
- is_complete=lambda _r: (False, "4 of 7 stacks are not deployed"),
- max_nudges=1,
- )
- nudge = fake_client[0].prompts[1]
- assert "4 of 7 stacks are not deployed" in nudge
- assert "Do not start over" in nudge
-
-
-def test_a_ceiling_stop_is_never_nudged(
- fake_client, transcript, resolver, tmp_path, monkeypatch
-):
- """Spending more on a run already stopped for spending is the one case where
- continuing is certainly wrong."""
-
- class _Stopped(_FakeResult):
- pass
-
- _Stopped.__name__ = "ResultMessage"
-
- async def receive(self):
- yield _Stopped(subtype="error_max_budget_usd")
-
- monkeypatch.setattr(_FakeClient, "receive_response", receive)
-
- result = _drive(
- transcript, resolver, tmp_path, is_complete=lambda _r: (False, "unfinished")
- )
- assert result.stopped_by_ceiling
- assert result.nudges == 0
- assert fake_client[0].prompts == ["go"]
-
-
-def test_nudges_are_recorded_in_the_transcript(
- fake_client, transcript, resolver, tmp_path
-):
- """A run that only completed because it was pushed must say so in the record --
- the transcript outlives both the terminal and the temp project."""
- _drive(
- transcript,
- resolver,
- tmp_path,
- is_complete=lambda _r: (False, "still composing"),
- max_nudges=1,
- )
- lines = transcript.path.read_text()
- assert '"kind": "nudge"' in lines
- assert '"kind": "nudge_exhausted"' in lines
- assert "still composing" in lines
-
-
-# --------------------------------------------------------------------------- #
-# Billable vs free
-# --------------------------------------------------------------------------- #
-
-
-def test_only_the_security_tier_counts_as_free():
- stacks = [
- ("itaaa-dev-security", "CREATE_COMPLETE"),
- ("itaaa-dev-frontend", "CREATE_COMPLETE"),
- ]
- assert standing.billable(stacks) == [("itaaa-dev-frontend", "CREATE_COMPLETE")]
-
-
-def test_an_unrecognised_tier_counts_as_billable():
- """The safe direction: an unknown stack under the run's namespace is likelier a
- tier this table has not been taught than a second free IAM stack."""
- assert standing.billable([("itaaa-dev-whatever", "CREATE_COMPLETE")])
-
-
-def test_the_pointer_does_not_call_an_iam_only_run_billable(tmp_path, monkeypatch):
- monkeypatch.setattr(standing, "HERE", tmp_path)
- text = standing.render_pointer(
- "itaaa", "dev", "us-east-1", "sso", [("itaaa-dev-security", "CREATE_COMPLETE")]
- )
- assert "Nothing standing here is billable" in text
- assert "IAM only, not billable" in text
- # Still names the way out: the namespace must be free for the next run.
- assert "make teardown-namespace NAMESPACE=itaaa" in text
-
-
-def test_absent_tiers_names_what_is_missing():
- absent = standing.absent_tiers("itaaa", "dev", ["itaaa-dev-security"])
- assert "frontend" in absent
- assert "security" not in absent
- assert len(absent) == len(standing.EXPECTED_TIERS) - 1
-
-
-# --------------------------------------------------------------------------- #
-# What "finished" means
-# --------------------------------------------------------------------------- #
-
-
-def _compose(project):
- """A project whose eight artifacts all exist."""
- (project / "scripts").mkdir(parents=True, exist_ok=True)
- for rel in artifacts.EXPECTED_ARTIFACTS:
- (project / rel).write_text("x\n")
- return project
-
-
-def _all_seven(namespace):
- return [
- (f"{namespace}-dev-{tier}", "CREATE_COMPLETE")
- for tier in standing.EXPECTED_TIERS
- ]
-
-
-def test_compose_is_unfinished_until_every_artifact_exists(tmp_path):
- done, missing = lifecycle.compose_state(tmp_path)
- assert not done
- assert "artifacts are still absent" in missing
- assert "scripts/deploy.mk" in missing
-
- done, missing = lifecycle.compose_state(_compose(tmp_path))
- assert done and missing == ""
-
-
-def test_deploy_is_unfinished_when_composition_is(tmp_path):
- """Reported as a composition gap, not as absent stacks.
-
- The naming matters more than the boolean: "4 of 7 stacks are not deployed" sends a
- reader to CloudFormation, when what happened is that no Makefile was ever written.
- """
- done, missing = lifecycle.deploy_state(
- tmp_path, "itaaa", "dev", "us-east-1", lister=lambda *a: ([], "")
- )
- assert not done
- assert "artifacts are still absent" in missing
- assert "Nothing could have been deployed" in missing
-
-
-def test_deploy_is_unfinished_when_stacks_are_missing(tmp_path):
- done, missing = lifecycle.deploy_state(
- _compose(tmp_path),
- "itaaa",
- "dev",
- "us-east-1",
- lister=lambda *a: ([("itaaa-dev-security", "CREATE_COMPLETE")], ""),
- )
- assert not done
- assert "6 of 7 stacks are not deployed" in missing
- assert "frontend" in missing
-
-
-def test_deploy_is_finished_with_all_seven(tmp_path):
- done, missing = lifecycle.deploy_state(
- _compose(tmp_path),
- "itaaa",
- "dev",
- "us-east-1",
- lister=lambda *a: (_all_seven("itaaa"), ""),
- )
- assert done and missing == ""
-
-
-def test_an_errored_listing_is_finished_for_the_nudge_and_a_failure_for_the_gate(
- tmp_path,
-):
- """The same three-way state, opposite answers, and both are deliberate.
-
- Nudging on an unknown listing spends money re-querying a deployment that may be
- complete; passing the gate on one lets twenty-one assertions fail against nothing.
- """
- project = _compose(tmp_path)
-
- def broken(*_args):
- return [], "ExpiredToken"
-
- done, missing = lifecycle.deploy_state(
- project, "itaaa", "dev", "us-east-1", lister=broken
- )
- assert done and missing == ""
-
- done, missing = lifecycle.deploy_state(
- project, "itaaa", "dev", "us-east-1", strict=True, lister=broken
- )
- assert not done
- assert "ExpiredToken" in missing
- assert "unknown" in missing
diff --git a/internal/integration-tests/harness/tests/test_reaper.py b/internal/integration-tests/harness/tests/test_reaper.py
deleted file mode 100644
index 3c5fa7c..0000000
--- a/internal/integration-tests/harness/tests/test_reaper.py
+++ /dev/null
@@ -1,348 +0,0 @@
-"""Offline tests for teardown ordering, namespace scoping, and the version purge.
-
-Every assertion here is on **state reached or error raised**, never on a command
-having been issued. The reference harness's most expensive bug was a test asserting
-that ``aws s3 rm`` *ran* — which is exactly the assumption that failed, so it passed
-against two broken runs.
-"""
-
-from __future__ import annotations
-
-import itertools
-import json
-from pathlib import Path
-
-import pytest
-
-from harness import reaper
-from harness import standing
-from harness import sweep
-
-# --------------------------------------------------------------------------- #
-# Teardown ordering: nothing may tie with the logs rank
-# --------------------------------------------------------------------------- #
-
-
-def test_nothing_ties_with_the_logs_rank():
- """Python's sort is STABLE, so two entries sharing a rank keep input order.
-
- A tier tying with ``logs`` therefore deletes before it on some runs and after
- it on others, depending on how the stack listing happened to come back — and the
- run where it deletes first fails on a bucket still being written to.
- """
- ranks = {tier: sweep.tier_rank(f"ns-dev-{tier}") for tier in sweep.TIER_ORDER}
- ranks[""] = sweep.tier_rank("ns-dev-somethingnew")
- logs_rank = sweep.tier_rank("ns-dev-logs")
-
- assert logs_rank == sweep.LOGS_RANK
- for tier, rank in ranks.items():
- assert rank != logs_rank, f"{tier} ties with logs at rank {rank}"
-
-
-def test_logs_is_absent_from_tier_order():
- """Appending 'logs' to TIER_ORDER is the tempting form and is what creates the
- tie with an unknown tier."""
- assert "logs" not in sweep.TIER_ORDER
-
-
-def test_unknown_tiers_sort_between_security_and_logs():
- unknown = sweep.tier_rank("ns-dev-brandnew")
- assert sweep.tier_rank("ns-dev-security") < unknown < sweep.tier_rank("ns-dev-logs")
-
-
-def test_logs_sorts_last_for_every_input_permutation():
- stacks = [f"ns-dev-{t}" for t in ("logs", "frontend", "queue", "brandnew")]
- for perm in itertools.permutations(stacks):
- assert sweep.teardown_order(list(perm))[-1] == "ns-dev-logs"
-
-
-def test_known_tiers_come_out_in_documented_order():
- stacks = [f"ns-dev-{t}" for t in reversed(sweep.TIER_ORDER)]
- ordered = sweep.teardown_order(stacks)
- assert ordered == [f"ns-dev-{t}" for t in sweep.TIER_ORDER]
-
-
-# --------------------------------------------------------------------------- #
-# Namespace scoping: pure, and never widening
-# --------------------------------------------------------------------------- #
-
-GROUPED = {
- "itaaa": ["itaaa-dev-frontend", "itaaa-dev-backend", "itaaa-dev-logs"],
- "itbbb": ["itbbb-dev-frontend", "itbbb-dev-queue"],
-}
-
-
-def test_selection_is_decidable_with_no_credentials():
- """A pure function over an already-retrieved listing, so the most dangerous
- behaviour in the system is testable without deleting anything."""
- assert sweep.select_namespaces(GROUPED, "itaaa") == {"itaaa": GROUPED["itaaa"]}
-
-
-def test_a_teardown_scoped_to_one_namespace_names_no_stack_from_another():
- selected = sweep.select_namespaces(GROUPED, "itaaa")
- named = [s for stacks in selected.values() for s in stacks]
- assert not any(s.startswith("itbbb") for s in named)
-
-
-def test_an_unmatched_namespace_selects_nothing():
- assert sweep.select_namespaces(GROUPED, "itzzz") == {}
-
-
-def test_a_missing_namespace_does_not_select_everything_at_the_deleting_boundary(
- monkeypatch,
-):
- """``select_namespaces`` passes a listing through when given no namespace, so the
- REFUSAL has to live at the deleting boundary — and that is what is asserted."""
- monkeypatch.setenv("IPA_IT_TEARDOWN", "1")
- deleted: list[str] = []
- monkeypatch.setattr(sweep, "_run", lambda *a, **k: deleted.append(a) or (True, ""))
-
- rc = sweep.cmd_teardown("us-east-1", namespace=None)
-
- assert rc != 0
- assert deleted == []
-
-
-def test_teardown_refuses_without_the_environment_opt_in(monkeypatch):
- monkeypatch.delenv("IPA_IT_TEARDOWN", raising=False)
- deleted: list[str] = []
- monkeypatch.setattr(sweep, "_run", lambda *a, **k: deleted.append(a) or (True, ""))
-
- rc = sweep.cmd_teardown("us-east-1", namespace="itaaa")
-
- assert rc != 0
- assert deleted == []
-
-
-def test_group_by_namespace_splits_on_the_env_marker():
- grouped = sweep.group_by_namespace(
- ["itaaa-dev-frontend", "itbbb-dev-logs", "unrelated-prod-thing"], env="dev"
- )
- assert set(grouped) == {"itaaa", "itbbb"}
-
-
-# --------------------------------------------------------------------------- #
-# The standing-resource pointer
-# --------------------------------------------------------------------------- #
-
-
-def test_pointer_is_removed_only_for_a_confirmed_deletion(monkeypatch, tmp_path):
- """Reference F5. A pointer surviving a successful teardown errs safe but teaches
- a reader to distrust the next pointer, which may be true."""
- monkeypatch.setattr(standing, "HERE", tmp_path)
- monkeypatch.setenv("IPA_IT_TEARDOWN", "1")
-
- p = standing.pointer_path("itaaa")
- p.write_text("standing\n")
-
- monkeypatch.setattr(
- sweep, "list_it_stacks", lambda *a, **k: (["itaaa-dev-frontend"], "")
- )
- monkeypatch.setattr(sweep, "_run", lambda *a, **k: (True, ""))
-
- sweep.cmd_teardown("us-east-1", "itaaa")
-
- assert not p.exists()
-
-
-def test_pointer_survives_a_partial_teardown(monkeypatch, tmp_path):
- monkeypatch.setattr(standing, "HERE", tmp_path)
- monkeypatch.setenv("IPA_IT_TEARDOWN", "1")
-
- p = standing.pointer_path("itaaa")
- p.write_text("standing\n")
-
- monkeypatch.setattr(
- sweep, "list_it_stacks", lambda *a, **k: (["itaaa-dev-frontend"], "")
- )
- monkeypatch.setattr(sweep, "_run", lambda *a, **k: (False, "AccessDenied"))
-
- rc = sweep.cmd_teardown("us-east-1", "itaaa")
-
- assert rc != 0
- assert p.exists(), "a failed teardown must keep the only record of what stands"
-
-
-def test_live_stacks_never_raises_and_reports_its_error(monkeypatch):
- """It runs at session teardown, where an exception would replace the
- standing-resource report with a fixture error."""
-
- def boom(*a, **k):
- raise OSError("credential chain exploded")
-
- monkeypatch.setattr(standing.subprocess, "run", boom)
- stacks, error = standing.live_stacks("itaaa", "us-east-1")
-
- assert stacks == []
- assert "credential chain exploded" in error
-
-
-def test_pointer_text_is_readable_without_credentials(tmp_path, monkeypatch):
- monkeypatch.setattr(standing, "HERE", tmp_path)
- text = standing.render_pointer(
- "itaaa", "dev", "us-east-1", "sso", [("itaaa-dev-frontend", "CREATE_COMPLETE")]
- )
- assert "itaaa" in text
- assert "make teardown-namespace NAMESPACE=itaaa" in text
- assert "BILLABLE" in text
-
-
-def test_parse_env_reads_one_file_with_no_run_folder_merge(tmp_path):
- env = tmp_path / ".env"
- env.write_text("# comment\nAPP_NAMESPACE=itaaa\nLOG_BUCKET_NAME='b'\nbroken\n")
- values = standing.parse_env(env)
- assert values == {"APP_NAMESPACE": "itaaa", "LOG_BUCKET_NAME": "b"}
-
-
-# --------------------------------------------------------------------------- #
-# The version purge
-# --------------------------------------------------------------------------- #
-
-
-def test_version_purge_drains_both_versions_and_delete_markers():
- """Draining only ``Versions`` leaves the delete markers, and ``DeleteBucket``
- refuses on markers alone."""
- calls: list[list[str]] = []
- pages = [
- json.dumps(
- {
- "Versions": [{"Key": "a", "VersionId": "v1"}],
- "DeleteMarkers": [{"Key": "a", "VersionId": "m1"}],
- }
- ),
- json.dumps({}),
- ]
-
- def runner(argv, timeout, cwd=None):
- calls.append(argv)
- if "list-object-versions" in argv:
- return True, pages.pop(0)
- return True, ""
-
- ok, detail = reaper.purge_versions("b", "us-east-1", runner=runner)
-
- assert ok
- assert "emptied (2 versions/markers removed)" in detail
- deleted = json.loads(
- next(a[a.index("--delete") + 1] for a in calls if "--delete" in a)
- )
- assert {o["VersionId"] for o in deleted["Objects"]} == {"v1", "m1"}
-
-
-def test_a_failed_version_listing_does_not_report_emptied():
- """The reference's most expensive class of bug in miniature: a report that a
- command ran standing in for a state having been reached."""
-
- def runner(argv, timeout, cwd=None):
- if "list-object-versions" in argv:
- return False, "AccessDenied"
- return True, ""
-
- ok, detail = reaper.purge_versions("b", "us-east-1", runner=runner)
-
- assert ok is False
- assert "emptied" not in detail
- assert "AccessDenied" in detail
-
-
-def test_purging_an_already_empty_bucket_is_a_no_op_not_an_error():
- ok, detail = reaper.purge_versions(
- "b", "us-east-1", runner=lambda *a, **k: (True, json.dumps({}))
- )
- assert ok
- assert "0 versions/markers removed" in detail
-
-
-def test_empty_bucket_treats_the_purge_as_authoritative():
- """``aws s3 rm`` succeeding tells us nothing about whether ``DeleteBucket`` will:
- on a versioned bucket it leaves the bucket un-deletable while making it look
- empty."""
-
- def runner(argv, timeout, cwd=None):
- if "list-object-versions" in argv:
- return False, "listing blew up"
- return True, "" # `s3 rm` "succeeds"
-
- ok, detail = reaper.empty_bucket("b", "us-east-1", runner=runner)
-
- assert ok is False
- assert "rm=ok" in detail
-
-
-def test_version_purge_stops_at_the_page_cap_and_reports_rather_than_looping():
- def runner(argv, timeout, cwd=None):
- if "list-object-versions" in argv:
- return True, json.dumps({"Versions": [{"Key": "k", "VersionId": "v"}]})
- return True, ""
-
- ok, detail = reaper.purge_versions("b", "us-east-1", runner=runner)
-
- assert ok is False
- assert "still not empty" in detail
-
-
-# --------------------------------------------------------------------------- #
-# The reaper refuses what it cannot describe
-# --------------------------------------------------------------------------- #
-
-
-def test_reaper_refuses_when_deploy_mk_is_absent(tmp_path: Path):
- """Those artifacts encode the reverse ordering and the deletion waits. Without
- them the reaper would improvise an order against infrastructure it cannot
- describe."""
- with pytest.raises(reaper.ReaperRefused, match="deploy.mk is absent"):
- reaper.assert_reapable(tmp_path)
-
-
-def test_reaper_accepts_a_project_carrying_deploy_mk(tmp_path: Path):
- (tmp_path / "scripts").mkdir()
- (tmp_path / "scripts" / "deploy.mk").write_text("deploy:\n\t@true\n")
- reaper.assert_reapable(tmp_path) # must not raise
-
-
-def test_reap_refuses_before_deleting_anything(tmp_path: Path):
- calls: list[list[str]] = []
-
- with pytest.raises(reaper.ReaperRefused):
- reaper.reap(
- tmp_path, "itaaa", "dev", "us-east-1",
- log_bucket="b",
- runner=lambda argv, t, cwd=None: calls.append(argv) or (True, ""),
- )
-
- assert calls == []
-
-
-def test_reap_empties_the_log_bucket_twice(tmp_path: Path):
- """MAKEFILE_TEMPLATES.md states the log bucket keeps receiving CloudFront logs
- until the frontend stack is torn down, so a single pre-pass races against
- delivery that continues after it."""
- (tmp_path / "scripts").mkdir()
- (tmp_path / "scripts" / "deploy.mk").write_text("deploy:\n\t@true\n")
-
- def runner(argv, timeout, cwd=None):
- if "list-object-versions" in argv:
- return True, json.dumps({})
- return True, ""
-
- report = reaper.reap(
- tmp_path, "itaaa", "dev", "us-east-1",
- log_bucket="logs-b", runner=runner,
- )
-
- names = [s.name for s in report.steps]
- assert sum(1 for n in names if "logs-b" in n) == 2
- assert any("re-empty" in n for n in names)
-
-
-def test_reap_deletes_the_security_stack(tmp_path: Path):
- """/ipa-destroy leaves it standing."""
- (tmp_path / "scripts").mkdir()
- (tmp_path / "scripts" / "deploy.mk").write_text("deploy:\n\t@true\n")
-
- report = reaper.reap(
- tmp_path, "itaaa", "dev", "us-east-1",
- runner=lambda argv, t, cwd=None: (True, ""),
- )
-
- assert any("itaaa-dev-security" in s.name for s in report.steps)
diff --git a/internal/integration-tests/harness/tests/test_redaction.py b/internal/integration-tests/harness/tests/test_redaction.py
deleted file mode 100644
index 6faa661..0000000
--- a/internal/integration-tests/harness/tests/test_redaction.py
+++ /dev/null
@@ -1,193 +0,0 @@
-"""Offline tests pinning the secret-redaction filter and the gate scanner.
-
-In the offline tier because they must run without deploying anything — the value being
-redacted is the one secret this harness generates, and a test that needed a live
-Cognito pool to check the filter would only run on the billable path.
-
-The planted values below are named `canary`, not `secret`, and the name is the whole
-reason. A test for a redaction filter has to contain something shaped exactly like a
-credential, and both scanners that fire on it key off the ASSIGNED NAME rather than the
-value: bandit's B105 and detect-secrets' keyword detector each flagged all three lines
-as `secret = "..."` and neither flags the identical value as `canary = "..."` — measured
-2026-08-18. Renaming is the only lever that holds here. `# pragma: allowlist secret`
-does NOT work: ASH runs detect-secrets with `filters_used: []`, and inline allowlisting
-is implemented as a filter, so with no filters configured the pragma is never consulted
-(also measured). Nor does a `suppressions:` entry in `.ash/ash.yaml` — those do not
-filter the GitLab SAST report at all.
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-from harness import cognito_auth
-from harness import driver
-from harness import standing
-
-
-# --------------------------------------------------------------------------- #
-# The redaction filter
-# --------------------------------------------------------------------------- #
-
-
-def test_the_transcript_writer_redacts_a_registered_secret(tmp_path: Path):
- """Keyed on the LIVE VALUE at the writer, not applied by care at each write site —
- care at each site is what a later-added sink escapes."""
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- canary = "Sup3rS3cret!Value"
- transcript.register_secret(canary)
-
- transcript.record("assistant_text", text=f"the password is {canary} ok")
-
- written = transcript.path.read_text()
- assert canary not in written
- assert "***REDACTED***" in written
-
-
-def test_redaction_covers_a_secret_nested_anywhere_in_the_payload(tmp_path: Path):
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- canary = "An0ther!Secret"
- transcript.register_secret(canary)
-
- transcript.record(
- "ask_user_question",
- questions=[{"question": "q", "note": f"leaked {canary}"}],
- answers={"q": canary},
- )
-
- assert canary not in transcript.path.read_text()
-
-
-def test_an_unregistered_value_is_not_redacted(tmp_path: Path):
- """The filter must not be a blanket scrubber: over-redaction would hide the
- verbatim gate text the transcript exists to preserve."""
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- transcript.record("assistant_text", text="Proceed with deployment? (yes/no):")
- assert "Proceed with deployment?" in transcript.path.read_text()
-
-
-def test_registering_the_same_secret_twice_is_idempotent(tmp_path: Path):
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- transcript.register_secret("x")
- transcript.register_secret("x")
- transcript.register_secret("")
- assert transcript.redactions == ["x"]
-
-
-def test_the_standing_report_can_be_written_through_the_redaction_filter(tmp_path):
- """The standing-resource report is the second of the three sinks."""
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- canary = "P0inter!Secret"
- transcript.register_secret(canary)
-
- text = standing.render_pointer(
- "itaaa", "dev", "us-east-1", "sso", [], {"APP_URL": f"https://x/{canary}"}
- )
- assert canary not in transcript.redact(text)
-
-
-# --------------------------------------------------------------------------- #
-# The generated password
-# --------------------------------------------------------------------------- #
-
-
-def test_the_generated_password_satisfies_all_four_required_classes():
- """All four are required by cognito.yml's PasswordPolicy, and the password is
- built BY CLASS rather than by rejection sampling so it cannot fail intermittently.
- """
- import string
-
- for _ in range(200):
- pw = cognito_auth.generate_password()
- assert len(pw) >= 8
- assert any(c in string.ascii_lowercase for c in pw)
- assert any(c in string.ascii_uppercase for c in pw)
- assert any(c in string.digits for c in pw)
- assert any(c in "!@#$%^&*()-_=+" for c in pw)
-
-
-def test_generated_passwords_differ():
- assert len({cognito_auth.generate_password() for _ in range(50)}) == 50
-
-
-# --------------------------------------------------------------------------- #
-# The gate scanner
-# --------------------------------------------------------------------------- #
-
-
-def test_gate_markers_match_case_insensitively(tmp_path: Path):
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- hit = driver.scan_for_gates(transcript, "PROCEED WITH DEPLOYMENT? (YES/NO):")
- assert hit == ["deploy:proceed"]
-
-
-def test_a_gate_hit_records_the_full_surrounding_text_verbatim(tmp_path: Path):
- """What the agent SAID while crossing a billable boundary is the finding; a
- boolean "did it proceed" discards the finding's entire content."""
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- text = (
- "I will now deploy seven stacks costing real money.\n"
- "Proceed with deployment? (yes/no):\n"
- "Answering yes because this run is unattended."
- )
- driver.scan_for_gates(transcript, text)
-
- written = transcript.path.read_text()
- assert "costing real money" in written
- assert "unattended" in written
-
-
-def test_text_with_no_marker_records_no_gate(tmp_path: Path):
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
- assert driver.scan_for_gates(transcript, "just some ordinary output") == []
- # Nothing recorded means the file was never opened for append, so it does not
- # exist -- which is the correct "no gate" state, not an empty file.
- written = transcript.path.read_text() if transcript.path.exists() else ""
- assert written == ""
-
-
-def test_every_gate_marker_is_lowercase():
- """The scanner lowercases the haystack, so an upper-case marker never matches."""
- for gate, marker in driver.BILLABLE_GATE_MARKERS.items():
- assert marker == marker.lower(), f"{gate} marker is not lowercase: {marker!r}"
-
-
-# --------------------------------------------------------------------------- #
-# The offline tier must not depend on the optional SDK extra
-# --------------------------------------------------------------------------- #
-
-
-def test_driver_imports_without_the_agent_sdk():
- """``driver`` must import on a machine that has not installed the extra.
-
- ``conftest.py`` imports ``driver``, so a module-level SDK import means pytest
- cannot even COLLECT the offline tests — which defeats the whole point of putting
- the SDK behind an extra. Caught by running the suite on a fresh checkout, where it
- failed with ``ModuleNotFoundError``.
-
- This test passes either way; what it pins is that the SDK-dependent names are
- accessed lazily, so removing the guard reintroduces a collection error rather than
- a test failure.
- """
- assert hasattr(driver, "SDK_AVAILABLE")
- assert isinstance(driver.SDK_AVAILABLE, bool)
- # The pure-Python surface the offline tier uses is present regardless.
- assert driver.BILLABLE_GATE_MARKERS
- assert driver.Transcript is not None
- assert driver.COMPOSE_PROMPT and driver.FULL_PROMPT
- assert driver.STOP_AFTER_COMPOSE and driver.FULL_LIFECYCLE
-
-
-def test_building_options_without_the_sdk_fails_actionably(monkeypatch, tmp_path):
- """A missing extra must name the fix, not raise a bare NameError."""
- monkeypatch.setattr(driver, "SDK_AVAILABLE", False)
- monkeypatch.setattr(driver, "SDK_IMPORT_ERROR", "No module named 'claude_agent_sdk'")
-
- from harness import answers
- import pytest as _pytest
-
- resolver = answers.AnswerResolver(namespace="itaaa0", profile="p")
- transcript = driver.Transcript(path=tmp_path / "t.jsonl")
-
- with _pytest.raises(RuntimeError, match="uv sync --all-extras"):
- driver.build_options(tmp_path, resolver, transcript)
diff --git a/internal/integration-tests/harness/tests/test_workspace.py b/internal/integration-tests/harness/tests/test_workspace.py
deleted file mode 100644
index c30a52a..0000000
--- a/internal/integration-tests/harness/tests/test_workspace.py
+++ /dev/null
@@ -1,185 +0,0 @@
-"""Offline tests for temp-project construction.
-
-No AWS credentials, no agent. Each test pins a failure that would otherwise
-surface many minutes into a paid run.
-"""
-
-from __future__ import annotations
-
-import subprocess # nosec B404 - argv lists only, shell=False
-from pathlib import Path
-
-import pytest
-
-from harness import workspace
-
-
-def _git(repo: Path, *args: str) -> None:
- subprocess.run( # nosec B603 B607 - argv list, shell=False
- ["git", *args], cwd=repo, capture_output=True, text=True, check=True
- )
-
-
-@pytest.fixture
-def fake_repo(tmp_path: Path) -> Path:
- """A miniature repository carrying every artifact ``build()`` requires."""
- repo = tmp_path / "repo"
- repo.mkdir()
- _git(repo, "init", "-q")
- _git(repo, "config", "user.email", "t@example.invalid")
- _git(repo, "config", "user.name", "T")
-
- for rel in workspace.REQUIRED_ARTIFACTS:
- p = repo / rel
- p.parent.mkdir(parents=True, exist_ok=True)
- p.write_text(f"committed content of {rel}\n")
-
- # Mirror the real repository's ignore rules: .env and scripts/*.mk are ignored.
- (repo / ".gitignore").write_text(".env\n")
- (repo / "scripts").mkdir(exist_ok=True)
- (repo / "scripts" / ".gitignore").write_text("*.mk\n")
-
- _git(repo, "add", "-A")
- _git(repo, "commit", "-q", "-m", "initial")
- return repo
-
-
-def test_build_produces_every_required_artifact(fake_repo: Path, tmp_path: Path):
- src = workspace.build(tmp_path / "out", repo=fake_repo)
- for rel in workspace.REQUIRED_ARTIFACTS:
- assert (src.root / rel).is_file(), rel
-
-
-def test_build_reflects_the_working_tree_not_the_commit(fake_repo: Path, tmp_path: Path):
- """The single most important property of this module.
-
- ``git archive HEAD`` would archive the COMMIT, so a maintainer testing an
- uncommitted skill edit would get a green run describing a tree nobody has.
- """
- edited = fake_repo / ".claude/skills/ipa-stack-backend/SKILL.md"
- edited.write_text("UNCOMMITTED EDIT under test\n")
-
- src = workspace.build(tmp_path / "out", repo=fake_repo)
-
- copied = (src.root / ".claude/skills/ipa-stack-backend/SKILL.md").read_text()
- assert copied == "UNCOMMITTED EDIT under test\n"
- assert "committed content" not in copied
-
-
-def test_build_includes_an_untracked_file(fake_repo: Path, tmp_path: Path):
- (fake_repo / "infra/cfn/newtier").mkdir(parents=True)
- (fake_repo / "infra/cfn/newtier/newtier.yml").write_text("never committed\n")
-
- src = workspace.build(tmp_path / "out", repo=fake_repo)
-
- assert (src.root / "infra/cfn/newtier/newtier.yml").is_file()
-
-
-def test_build_excludes_an_ignored_file(fake_repo: Path, tmp_path: Path):
- (fake_repo / ".env").write_text("APP_NAMESPACE=leaked\n")
-
- src = workspace.build(tmp_path / "out", repo=fake_repo)
-
- assert not (src.root / ".env").exists()
-
-
-def test_build_records_the_commit_and_the_dirty_flag(fake_repo: Path, tmp_path: Path):
- clean = workspace.build(tmp_path / "clean", repo=fake_repo)
- assert len(clean.commit) == 40
- assert clean.dirty is False
-
- (fake_repo / "app-lib/pyproject.toml").write_text("dirtied\n")
- dirty = workspace.build(tmp_path / "dirty", repo=fake_repo)
- assert dirty.dirty is True
- assert "dirty" in dirty.describe()
-
-
-def test_build_refuses_a_destination_inside_the_repository(fake_repo: Path):
- """A nested temp project is picked up by git status, ./lint and ASH."""
- with pytest.raises(AssertionError, match="outside the repository"):
- workspace.build(fake_repo / "nested" / "out", repo=fake_repo)
-
-
-def test_build_refuses_when_the_copy_would_contain_env(fake_repo: Path, tmp_path: Path):
- """The precondition keeping /ipa-init out of its uninterceptable branch."""
- # Force .env to be TRACKED, so the copy mechanism would carry it.
- (fake_repo / ".gitignore").write_text("")
- (fake_repo / ".env").write_text("APP_NAMESPACE=leaked\n")
- _git(fake_repo, "add", "-A")
- _git(fake_repo, "commit", "-q", "-m", "track env")
-
- with pytest.raises(AssertionError, match=r"must not contain \.env"):
- workspace.build(tmp_path / "out", repo=fake_repo)
-
-
-def test_build_refuses_when_the_copy_would_contain_a_gating_makefile(
- fake_repo: Path, tmp_path: Path
-):
- (fake_repo / "scripts" / ".gitignore").write_text("")
- (fake_repo / "scripts" / "deploy.mk").write_text("deploy:\n\t@true\n")
- _git(fake_repo, "add", "-A")
- _git(fake_repo, "commit", "-q", "-m", "track mk")
-
- with pytest.raises(AssertionError, match="gating generated Makefiles"):
- workspace.build(tmp_path / "out", repo=fake_repo)
-
-
-def test_a_tracked_test_mk_is_tolerated(fake_repo: Path, tmp_path: Path):
- """`scripts/test.mk` is TRACKED in this repository despite `scripts/.gitignore`
- listing `*.mk` — git's ignore rules do not apply to an already-tracked file.
-
- So a working-tree copy carries it, and it must NOT fail the pristine check: it
- gates neither `/ipa-init`'s re-initialization branch nor `/ipa-compose`'s
- idempotent-refresh mode.
- """
- (fake_repo / "scripts" / ".gitignore").write_text("")
- (fake_repo / "scripts" / "test.mk").write_text("test:\n\t@true\n")
- _git(fake_repo, "add", "-A")
- _git(fake_repo, "commit", "-q", "-m", "track test.mk")
-
- src = workspace.build(tmp_path / "out", repo=fake_repo)
-
- assert (src.root / "scripts" / "test.mk").is_file()
-
-
-def test_test_mk_is_not_in_the_gating_set():
- assert "test.mk" not in workspace.GATING_MAKEFILES
- assert "deploy.mk" in workspace.GATING_MAKEFILES
-
-
-def test_build_refuses_when_a_required_artifact_is_missing(
- fake_repo: Path, tmp_path: Path
-):
- (fake_repo / "infra/cfn/queue/queue.yml").unlink()
- _git(fake_repo, "add", "-A")
- _git(fake_repo, "commit", "-q", "-m", "drop queue template")
-
- with pytest.raises(AssertionError, match="missing artifacts"):
- workspace.build(tmp_path / "out", repo=fake_repo)
-
-
-def test_building_leaves_the_repository_status_unchanged(fake_repo: Path, tmp_path: Path):
- before = subprocess.run( # nosec B603 B607
- ["git", "status", "--porcelain"],
- cwd=fake_repo, capture_output=True, text=True, check=True,
- ).stdout
- workspace.build(tmp_path / "out", repo=fake_repo)
- after = subprocess.run( # nosec B603 B607
- ["git", "status", "--porcelain"],
- cwd=fake_repo, capture_output=True, text=True, check=True,
- ).stdout
- assert before == after
-
-
-def test_the_real_repository_builds_clean(tmp_path: Path):
- """The assertions run against THIS repository, not only a fixture."""
- src = workspace.build(tmp_path / "out", repo=workspace.REPO_ROOT)
- assert src.file_count > 100
- assert not (src.root / ".env").exists()
- for name in workspace.GATING_MAKEFILES:
- assert not (src.root / "scripts" / name).exists(), name
- # The skills under test, the tier templates and both application trees.
- assert (src.root / ".claude/skills/ipa-compose/SKILL.md").is_file()
- assert (src.root / "infra/cfn/frontend/frontend.yml").is_file()
- assert (src.root / "web-client/package.json").is_file()
- assert (src.root / "app-lib/pyproject.toml").is_file()
diff --git a/internal/integration-tests/harness/workspace.py b/internal/integration-tests/harness/workspace.py
deleted file mode 100644
index 2c83bb9..0000000
--- a/internal/integration-tests/harness/workspace.py
+++ /dev/null
@@ -1,212 +0,0 @@
-"""Build the temp project the IPA lifecycle skills are driven against.
-
-The temp project is a copy of the repository's **working tree**, not of any commit.
-``git archive HEAD`` archives the COMMIT, so a maintainer testing an uncommitted
-skill edit would get a green run describing a tree nobody has — which is worse
-than a red run, because it is believed.
-
-Three properties are asserted inside ``build()`` rather than only in the test
-module, so a caller that skips the tests cannot produce a subtly wrong temp
-project:
-
-1. **No ``.env`` and no ``scripts/*.mk``.** Both are git-ignored
- (``.gitignore:23``, ``scripts/.gitignore``), so a tracked-plus-untracked copy
- naturally has neither. It is asserted anyway because the absence is
- load-bearing and the ignore rules are editable: it is the precondition that
- keeps ``/ipa-init`` on its first-run path. Its re-initialization branch asks a
- plain-text *"Which values would you like to change?"* that no permission hook
- and no tool callback can intercept, so a run starting from a configured project
- cannot be driven unattended at all.
-2. **Every artifact a later phase depends on is present.** A silently incomplete
- copy fails much later, inside a container build, with an error naming a missing
- file rather than a missing copy.
-3. **The destination is outside the repository.** A nested temp project is picked
- up by ``git status``, by ``./lint`` and by ASH, so a run would report findings
- against a copy of the tree rather than against the tree.
-"""
-
-from __future__ import annotations
-
-import shutil
-import subprocess # nosec B404 - argv lists only, shell=False; see .ash/ash.yaml
-import tempfile
-from dataclasses import dataclass
-from pathlib import Path
-
-from harness import aws
-
-#: Resolved from this file, never from the caller's cwd.
-#:
-#: ``parents[3]``, not ``parents[2]``: this module moved down one level into
-#: ``harness/``, so the hop count grew by one --
-#: ``harness/`` -> ``integration-tests/`` -> ``internal/`` -> repo root. Getting this
-#: wrong points the temp-project build at ``internal/`` and copies the wrong tree.
-REPO_ROOT = Path(__file__).resolve().parents[3]
-
-#: Each of these is something a later phase depends on. Their presence is asserted
-#: at build time so a truncated copy fails here rather than inside a build.
-REQUIRED_ARTIFACTS = (
- ".claude/skills/ipa-stack-backend/SKILL.md",
- "infra/cfn/queue/queue.yml",
- "infra/scripts/docker.mk",
- "web-client/package-lock.json",
- "app-lib/pyproject.toml",
-)
-
-
-@dataclass(frozen=True)
-class Source:
- """What the temp project was built from.
-
- Returned as data rather than logged, so the driver can put both fields in the
- transcript: a run's result must name the tree it ran against, and for a dirty
- tree the commit alone is a false description.
- """
-
- commit: str
- dirty: bool
- root: Path
- file_count: int
-
- def describe(self) -> str:
- return (
- f"{self.commit[:12]}{'-dirty' if self.dirty else ''} "
- f"({self.file_count} files) -> {self.root}"
- )
-
-
-def _git(repo: Path, *args: str) -> list[str]:
- """Run git with an argv list and return its stdout lines."""
- proc = subprocess.run( # nosec B603 B607 - argv list, shell=False
- ["git", *args],
- cwd=repo,
- capture_output=True,
- text=True,
- check=True,
- )
- return [line for line in proc.stdout.splitlines() if line.strip()]
-
-
-def tracked_and_untracked(repo: Path) -> list[str]:
- """Every file in the working tree that is not ignored.
-
- The union is the right set precisely BECAUSE it excludes ignored files: that
- is what makes the absence of ``.env`` and ``scripts/*.mk`` a property of the
- copy mechanism rather than a step someone could forget.
- """
- tracked = _git(repo, "ls-files")
- untracked = _git(repo, "ls-files", "--others", "--exclude-standard")
- return sorted(set(tracked) | set(untracked))
-
-
-def is_dirty(repo: Path) -> bool:
- return bool(_git(repo, "status", "--porcelain"))
-
-
-def head_commit(repo: Path) -> str:
- return _git(repo, "rev-parse", "HEAD")[0]
-
-
-def _copy_from_working_tree(src: Path, dest: Path) -> None:
- """Copy one file's CONTENTS from the working tree, preserving the exec bit."""
- dest.parent.mkdir(parents=True, exist_ok=True)
- shutil.copyfile(src, dest)
- shutil.copymode(src, dest)
-
-
-#: Generated Makefiles whose presence puts a lifecycle skill onto a branch the
-#: harness cannot drive. Deliberately NOT "any scripts/*.mk" -- see below.
-#:
-#: `scripts/test.mk` is TRACKED in this repository (`git ls-files scripts/`), even
-#: though `scripts/.gitignore` lists `*.mk`: git's ignore rules do not apply to a
-#: file that is already tracked. So a working-tree copy DOES carry one generated
-#: Makefile, and a blanket `*.mk` assertion fails on every healthy build.
-#:
-#: Narrowing to the gating artifacts is correct rather than merely convenient:
-#: - `.env` is what sends `/ipa-init` into its re-initialization branch, whose
-#: plain-text "Which values would you like to change?" no hook can intercept.
-#: - `scripts/deploy.mk` is what `/ipa-compose` Step 0.3 checks to choose its
-#: idempotent-refresh mode (`ipa-compose/SKILL.md:57`).
-#: `test.mk` gates neither, so its presence changes no skill's path.
-GATING_MAKEFILES = ("deploy.mk", "prepare.mk", "build.mk", "env.mk", "post-deploy.mk")
-
-
-def assert_pristine(dest: Path) -> None:
- """No ``.env``, and none of the generated Makefiles that gate a skill branch."""
- env_file = dest / ".env"
- if env_file.exists():
- raise AssertionError(
- f"temp project must not contain .env (found {env_file}); "
- "its presence sends /ipa-init into its re-initialization branch, "
- "whose plain-text prompt no hook can intercept"
- )
- leaked = sorted(
- name for name in GATING_MAKEFILES if (dest / "scripts" / name).exists()
- )
- if leaked:
- raise AssertionError(
- f"temp project must not contain gating generated Makefiles (found "
- f"{leaked} in {dest / 'scripts'}); /ipa-compose must generate them "
- "during the run, and their presence selects its idempotent-refresh mode"
- )
-
-
-def assert_required_artifacts(dest: Path) -> None:
- """Every artifact a later phase depends on is present."""
- missing = [rel for rel in REQUIRED_ARTIFACTS if not (dest / rel).exists()]
- if missing:
- raise AssertionError(
- f"temp project is missing artifacts a later phase depends on: {missing}"
- )
-
-
-def assert_outside_repo(dest: Path, repo: Path) -> None:
- """The temp project must not be nested under the repository."""
- if dest.resolve().is_relative_to(repo.resolve()):
- raise AssertionError(
- f"temp project must live outside the repository, got {dest} "
- f"under {repo}; a nested copy is picked up by git status, ./lint and ASH"
- )
-
-
-def build(dest: Path, repo: Path = REPO_ROOT) -> Source:
- """Copy the repository's working tree into ``dest`` and assert it is usable."""
- dest = Path(dest)
- repo = Path(repo)
-
- # Asserted BEFORE copying anything, so a bad destination costs nothing.
- assert_outside_repo(dest, repo)
-
- rels = tracked_and_untracked(repo)
- for rel in rels:
- src = repo / rel
- # ls-files can name a path that no longer exists (a staged deletion).
- if src.is_file():
- _copy_from_working_tree(src, dest / rel)
-
- assert_pristine(dest)
- assert_required_artifacts(dest)
-
- return Source(
- commit=head_commit(repo),
- dirty=is_dirty(repo),
- root=dest,
- file_count=len(rels),
- )
-
-
-def build_temp(repo: Path = REPO_ROOT, prefix: str = "ipa-it-") -> Source:
- """Build into a fresh temp directory outside the repository."""
- dest = Path(tempfile.mkdtemp(prefix=prefix))
- return build(dest, repo)
-
-
-#: The profile name the DRIVEN PROJECT needs in its ``.env`` -- never used to build a
-#: session. A re-export rather than a second implementation: one definition lives in
-#: ``harness.aws``, and this name is kept so existing callers keep working.
-#:
-#: It RAISES when ``AWS_PROFILE`` is unset. That is the change from the old behaviour,
-#: which fell back to ``"default"`` and wrote ``AWS_PROFILE=default`` into ``.env`` --
-#: accepted by ``/ipa-init``, then failing much later inside a generated Makefile with
-#: an error naming a missing profile rather than the missing configuration step.
-resolve_aws_profile = aws.profile_for_env
diff --git a/internal/integration-tests/pyproject.toml b/internal/integration-tests/pyproject.toml
deleted file mode 100644
index 373703f..0000000
--- a/internal/integration-tests/pyproject.toml
+++ /dev/null
@@ -1,22 +0,0 @@
-[project]
-name = "integration-tests"
-version = "0.1.0"
-description = "Maintainer-only integration harness that drives the real IPA lifecycle against a live AWS sandbox"
-readme = "README.md"
-requires-python = ">=3.12"
-dependencies = [
- "boto3>=1.35",
- "pytest>=8.3",
- # Declared explicitly even before anything calls it. The reference harness's
- # F3 was a missing `requests` in its own environment, masked by another bug
- # that fired first -- a bug can be load-bearing for a green-looking suite.
- "requests>=2.32",
-]
-
-[project.optional-dependencies]
-# Behind an extra so a machine without the SDK still COLLECTS the offline tests.
-# preflight.py's claude_agent_sdk probe reporting a negative outcome is the
-# expected reading on such a machine.
-agent = [
- "claude-agent-sdk>=0.1",
-]
diff --git a/internal/integration-tests/uv.lock b/internal/integration-tests/uv.lock
deleted file mode 100644
index 844ac64..0000000
--- a/internal/integration-tests/uv.lock
+++ /dev/null
@@ -1,974 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.12"
-resolution-markers = [
- "python_full_version >= '3.14' and sys_platform == 'win32'",
- "python_full_version >= '3.14' and sys_platform != 'win32'",
- "python_full_version < '3.14' and sys_platform == 'win32'",
- "python_full_version < '3.14' and sys_platform != 'win32'",
-]
-
-[[package]]
-name = "annotated-types"
-version = "0.8.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
-]
-
-[[package]]
-name = "anyio"
-version = "4.14.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
-]
-
-[[package]]
-name = "attrs"
-version = "26.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
-]
-
-[[package]]
-name = "boto3"
-version = "1.43.73"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "botocore" },
- { name = "jmespath" },
- { name = "s3transfer" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/47/e4/139572e2459b7e10cd3409888a7312b8b5054c85ea41b28af417002af1c4/boto3-1.43.73.tar.gz", hash = "sha256:6e6c755e5039f204882c01d7936a46abce539b3f5bbb534a23a3f2afbc86f576", size = 112665, upload-time = "2026-08-17T20:39:43.686Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/97/20/e4a415a5a3185bd13aec89603917203ff0b274caa9f0b0329d12aa397ff0/boto3-1.43.73-py3-none-any.whl", hash = "sha256:5b54da301c387abe30c5b3a5335652f1ebd73e814c725ba805d27a2d477ce547", size = 140024, upload-time = "2026-08-17T20:39:41.636Z" },
-]
-
-[[package]]
-name = "botocore"
-version = "1.43.73"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jmespath" },
- { name = "python-dateutil" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e3/ea/c8482ada4f409c91e6f9d640ee15838975f2a7c481dff98419a6d176b81a/botocore-1.43.73.tar.gz", hash = "sha256:0fa1e63c24b3531be3e1bc1687a88b3be9e63a430153f24edd93efc162bb1c51", size = 15963105, upload-time = "2026-08-17T20:39:37.94Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f2/9e/b0f2332029b4cc06281932f8e4908a9616ed756ad9da55fca07a949ea811/botocore-1.43.73-py3-none-any.whl", hash = "sha256:068433028e011ccbeab1dd7c46b1090c24e378397693c66e67ca571176498daa", size = 15653468, upload-time = "2026-08-17T20:39:34.332Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2026.7.22"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
-]
-
-[[package]]
-name = "cffi"
-version = "2.1.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
- { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
- { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
- { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
- { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
- { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
- { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
- { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
- { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
- { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
- { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
- { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
- { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
- { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
- { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
- { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
- { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
- { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
- { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
- { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
- { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
- { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
- { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
- { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
- { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
- { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
- { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
- { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
- { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
- { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
- { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
- { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
- { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
- { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
- { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
- { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
- { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
- { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
- { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
- { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
- { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
- { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
- { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
- { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
- { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
- { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
- { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
- { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
- { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
- { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
- { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
- { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
- { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
- { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
- { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
- { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
- { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
- { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
- { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
- { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
- { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
- { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
- { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
- { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
- { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
- { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
- { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
- { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
- { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
- { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
- { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
- { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
- { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
- { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
- { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
- { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
- { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
- { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
- { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
- { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
- { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
- { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
- { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
- { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
- { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
- { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
- { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
- { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
- { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
- { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
- { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
- { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
- { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
- { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
- { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
- { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
- { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
- { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
- { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
- { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
- { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
- { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
- { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
- { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
- { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
- { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
- { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
- { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
- { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
- { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
- { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
- { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
- { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
- { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
- { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
- { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
- { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
- { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
- { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
- { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
- { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
- { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
- { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
- { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
- { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
- { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
- { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
- { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
- { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
- { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
- { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
- { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
- { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
- { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
- { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
- { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
- { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
- { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
- { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
- { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
- { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
- { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
- { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
- { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
- { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
- { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
- { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
- { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
- { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
- { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
- { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
- { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
- { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
- { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
- { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
- { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
- { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
- { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
- { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
- { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
- { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
- { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
- { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
- { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
- { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
- { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
- { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
- { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
- { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
- { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
- { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
- { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
- { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
- { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
- { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
- { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
- { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
- { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
- { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
- { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
- { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
- { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
- { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
- { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
- { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
- { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
- { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
- { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
- { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
- { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
- { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
- { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
- { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
- { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
- { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
-]
-
-[[package]]
-name = "claude-agent-sdk"
-version = "0.2.139"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "mcp" },
- { name = "sniffio" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/11/b6/cfcdefed1f866a8ba372ef3884c8020dd54338d15d8b45d5a1ff7432cea1/claude_agent_sdk-0.2.139.tar.gz", hash = "sha256:4395ed541cdd4c13aeb1213b3b414b7e8a94cc060a773137e961882e81c174a7", size = 319519, upload-time = "2026-08-14T22:34:48.038Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ea/7f/f04c33553cbc69bb96d045dc38a6266726fad72130f22f405dfe9eb54bf1/claude_agent_sdk-0.2.139-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbc50cc475ec633cabfa36347646097e9b1466d53130e4a04a87308ff830c87b", size = 88043656, upload-time = "2026-08-14T22:34:53.027Z" },
- { url = "https://files.pythonhosted.org/packages/85/d0/a17f5318ca0220479f20fdf83fa54a838a0a13ee203495ff67c72c3f43a7/claude_agent_sdk-0.2.139-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1c08206b1603444582cd365effaf95d2a8248661f1492281fb2d529b0887c047", size = 93000433, upload-time = "2026-08-14T22:34:58.225Z" },
- { url = "https://files.pythonhosted.org/packages/c5/2e/5bcec31700d76ad2d5b9fc28521a75a66f464063dac11373cf8d61446a4f/claude_agent_sdk-0.2.139-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e69ae1a0b2af684c64839cc16e10b70800d9d2f57622b8c0d1739dd878cd7346", size = 97396659, upload-time = "2026-08-14T22:35:03.734Z" },
- { url = "https://files.pythonhosted.org/packages/c8/7f/582b3c1936c9f4ebc1bdc55a3923f1b680ef3c01928ffff1ea38eb84f637/claude_agent_sdk-0.2.139-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:34b289b3436fe24013f7b9cfe9f0a4e0806917a9ef8bbe829cda9a7b12d41a77", size = 98391889, upload-time = "2026-08-14T22:35:09.683Z" },
- { url = "https://files.pythonhosted.org/packages/56/54/d94af31d19b4e8d63d1b15002fd333ea77a040b7ab7a388044e511c8f9f6/claude_agent_sdk-0.2.139-py3-none-win_amd64.whl", hash = "sha256:9b76f0ffe216d6ca290d5f4f295ecb030dc496f101986ac99480a89d4abc6426", size = 100746507, upload-time = "2026-08-14T22:35:15.144Z" },
-]
-
-[[package]]
-name = "click"
-version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "cryptography"
-version = "50.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
- { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
- { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
- { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
- { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
- { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
- { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
- { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
- { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
- { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
- { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
- { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
- { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
- { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
- { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
- { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
- { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
- { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
- { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
- { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
- { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
- { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
- { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
- { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
- { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
- { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
- { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
- { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
- { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
- { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
- { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
- { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
- { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
- { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
- { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
- { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
- { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
- { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
- { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
-]
-
-[[package]]
-name = "h11"
-version = "0.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
-]
-
-[[package]]
-name = "httpcore"
-version = "1.0.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
-]
-
-[[package]]
-name = "httpx"
-version = "0.28.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
- { name = "idna" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
-]
-
-[[package]]
-name = "httpx-sse"
-version = "0.4.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
-]
-
-[[package]]
-name = "idna"
-version = "3.18"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
-]
-
-[[package]]
-name = "integration-tests"
-version = "0.1.0"
-source = { virtual = "." }
-dependencies = [
- { name = "boto3" },
- { name = "pytest" },
- { name = "requests" },
-]
-
-[package.optional-dependencies]
-agent = [
- { name = "claude-agent-sdk" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "boto3", specifier = ">=1.35" },
- { name = "claude-agent-sdk", marker = "extra == 'agent'", specifier = ">=0.1" },
- { name = "pytest", specifier = ">=8.3" },
- { name = "requests", specifier = ">=2.32" },
-]
-provides-extras = ["agent"]
-
-[[package]]
-name = "jmespath"
-version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
-]
-
-[[package]]
-name = "jsonschema"
-version = "4.26.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "jsonschema-specifications" },
- { name = "referencing" },
- { name = "rpds-py" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
-]
-
-[[package]]
-name = "jsonschema-specifications"
-version = "2025.9.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "referencing" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
-]
-
-[[package]]
-name = "mcp"
-version = "1.29.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "httpx" },
- { name = "httpx-sse" },
- { name = "jsonschema" },
- { name = "pydantic" },
- { name = "pydantic-settings" },
- { name = "pyjwt", extra = ["crypto"] },
- { name = "python-multipart" },
- { name = "pywin32", marker = "sys_platform == 'win32'" },
- { name = "sse-starlette" },
- { name = "starlette" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
- { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" },
-]
-
-[[package]]
-name = "packaging"
-version = "26.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "pycparser"
-version = "3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
-]
-
-[[package]]
-name = "pydantic"
-version = "2.13.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-types" },
- { name = "pydantic-core" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
-]
-
-[[package]]
-name = "pydantic-core"
-version = "2.46.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
- { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
- { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
- { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
- { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
- { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
- { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
- { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
- { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
- { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
- { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
- { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
- { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
- { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
- { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
- { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
- { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
- { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
- { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
- { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
- { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
- { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
- { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
- { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
- { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
- { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
- { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
- { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
- { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
- { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
- { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
- { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
- { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
- { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
- { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
- { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
- { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
- { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
- { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
- { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
- { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
- { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
- { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
- { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
- { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
- { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
- { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
- { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
- { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
- { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
- { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
- { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
- { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
- { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
- { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
- { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
- { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
- { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
- { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
- { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
- { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
- { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
- { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
- { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
-]
-
-[[package]]
-name = "pydantic-settings"
-version = "2.15.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pydantic" },
- { name = "python-dotenv" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.21.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
-]
-
-[[package]]
-name = "pyjwt"
-version = "2.13.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
-]
-
-[package.optional-dependencies]
-crypto = [
- { name = "cryptography" },
-]
-
-[[package]]
-name = "pytest"
-version = "9.1.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
-]
-
-[[package]]
-name = "python-dateutil"
-version = "2.9.0.post0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
-]
-
-[[package]]
-name = "python-dotenv"
-version = "1.2.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
-]
-
-[[package]]
-name = "python-multipart"
-version = "0.0.32"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
-]
-
-[[package]]
-name = "pywin32"
-version = "312"
-source = { registry = "https://pypi.org/simple" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
- { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
- { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
- { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
- { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
- { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
- { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
- { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
- { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
- { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
- { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
- { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
-]
-
-[[package]]
-name = "referencing"
-version = "0.37.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "rpds-py" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
-]
-
-[[package]]
-name = "requests"
-version = "2.34.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
-]
-
-[[package]]
-name = "rpds-py"
-version = "2026.6.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
- { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
- { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
- { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
- { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
- { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
- { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
- { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
- { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
- { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
- { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
- { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
- { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
- { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
- { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
- { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
- { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
- { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
- { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
- { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
- { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
- { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
- { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
- { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
- { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
- { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
- { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
- { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
- { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
- { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
- { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
- { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
- { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
- { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
- { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
- { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
- { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
- { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
- { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
- { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
- { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
- { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
- { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
- { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
- { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
- { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
- { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
- { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
- { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
- { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
- { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
- { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
- { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
- { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
- { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
- { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
- { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
- { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
- { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
- { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
- { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
- { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
- { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
- { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
- { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
- { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
- { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
- { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
- { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
- { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
- { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
- { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
- { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
- { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
- { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
- { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
- { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
- { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
- { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
- { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
- { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
- { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
- { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
- { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
- { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
- { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
- { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
-]
-
-[[package]]
-name = "s3transfer"
-version = "0.19.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "botocore" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
-]
-
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
-]
-
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
-]
-
-[[package]]
-name = "sse-starlette"
-version = "3.4.8"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "starlette" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" },
-]
-
-[[package]]
-name = "starlette"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
-]
-
-[[package]]
-name = "typing-inspection"
-version = "0.4.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
-]
-
-[[package]]
-name = "urllib3"
-version = "2.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
-]
-
-[[package]]
-name = "uvicorn"
-version = "0.52.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" },
-]
diff --git a/internal/release/README.md b/internal/release/README.md
deleted file mode 100644
index ddcfba2..0000000
--- a/internal/release/README.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# Internal Release
-
-Assets supporting IPA's release process. The process has **two stages**, and understanding the split is most of understanding the release path.
-
-| Stage | Where | Jobs | Effect |
-|-------|-------|------|--------|
-| 1 — internal release | GitLab | `gitlab:release:preview` (creates nothing), `gitlab:release` | Derives a version, tags `origin`, creates a GitLab Release. **Publishes nothing publicly.** |
-| 2 — public publish | GitLab → GitHub | `github:release` | Publishes a **chosen** tag to the public mirror with cumulative notes. Its first run, with no `CONFIRM_TAG`, is a dry run. |
-
-Both are manual. Release notes live on the annotated tag and the forge Release object — there is no committed changelog, so there is no release branch, no release merge request, and no marker commit.
-
-Stage 1 is documented in the [releasing runbook](../../docs/docs/developer-docs/internal/operations/runbooks/releasing.md). This file documents stage 2.
-
-Stage 2 cannot be chained off stage 1. A tag pushed by CI triggers no pipeline, so such a chain would silently never fire — the failure mode is "push a tag; nothing happens, no error anywhere." That is why publication is a separate manual act rather than a convenience that was never wired up.
-
-## What publishing actually withholds
-
-**Publishing does not withhold code.** The publish path amends the tip and force-pushes `main`, so the code of an internal release you never published still reaches the public repository the next time you publish anything.
-
-What publication withholds is **tags and Release objects**. That is what makes cumulative release notes a correctness requirement rather than a courtesy: a gap in the public tag sequence describes real, already-public code that no Release object documents. So the notes for a publish must cover everything since the last publish, not just the newest version.
-
-## The floor
-
-The **floor** is the newest version already published to GitHub. It is resolved by listing tags on the GitHub remote, filtering to strict semver, and taking the highest.
-
-```bash
-git ls-remote --tags github | awk '{print $2}' | sed 's|^refs/tags/||' \
- | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1
-```
-
-Three details are load-bearing:
-
-- **GitHub is the only authority on what GitHub has.** A marker file tracking "last published" would be a second source of truth, and drift means either republishing notes or silently dropping a version's worth.
-- **`sort -V`, not `sort`.** Plain `sort` ranks `v0.1.2` above `v0.1.10` — verified, and it will matter the first time a patch number reaches double digits.
-- **The floor supplies a NAME, not a commit.** GitHub's tag objects point at *amended* commits (the filtered tree has different SHAs by construction), so the notes range must resolve both endpoints against `origin`'s tags of those names.
-
-The notes window is `FLOOR..TAG`, **exclusive of the floor**, which is exactly "everything since the last publish."
-
-## Usage
-
-**An unset `CONFIRM_TAG` is the dry run.** It prints `TAG`, `FLOOR`, the full notes body, and the filtered-path report, publishes nothing, and ends with the exact value to re-run with. Publishing requires `CONFIRM_TAG` to equal the tag being published.
-
-```bash
-# Dry run — this is what happens with no CONFIRM_TAG set.
-internal/release/publish-github.sh
-
-# Same, stated explicitly. The flag still works, and it WINS over a
-# CONFIRM_TAG that happens to be exported in your shell.
-internal/release/publish-github.sh --dry-run
-
-# Publish a specific tag — including an older one.
-TAG=v0.4.0 internal/release/publish-github.sh
-CONFIRM_TAG=v0.4.0 TAG=v0.4.0 internal/release/publish-github.sh
-```
-
-In CI, run `github:release` once and read the `CONFIRM_TAG` value out of its output, then re-run with it. Publishing an older tag retags nothing internally — the internal tag already exists and is correct; only the public side is being brought forward.
-
-## Hazards
-
-Each of these fails **silently** if mishandled, which is why the script asserts rather than trusts.
-
-### The tag annotation loses its headings
-
-`git tag -a -m "$NOTES"` destroys every `##` and `###` heading. Git's default `--cleanup=strip` treats `#`-leading lines as comments, it **exits 0**, and it warns nobody — so a release published that way carries a flat, ungrouped, unversioned bullet list and nothing signals the loss.
-
-The publish path builds the tag object with `git mktag`, which stores the body byte-for-byte, and then asserts the headings survived. `.github/workflows/release.yml` asserts again when reading it back.
-
-### Force-moving tags in the operator's clone
-
-`git tag -f` is what corrupted local `v0.1.7`: the old `github-push.sh` retagged in the invoking clone after amending, leaving the local tag pointing at a mirror-amended commit while `origin` held a different commit for the same name. That disagreement poisons both version derivation (`git describe` returns the previous tag) and any publish range.
-
-**A worktree does not fix this.** Worktrees isolate the index, HEAD, and checkout — they share `refs/tags` with the main repository, so `git tag -f` inside one still moves the operator's tag. The publish path therefore writes **no local ref at all**: it builds the annotated tag object with `git mktag` and pushes it by SHA.
-
-`release.sh` asserts local tags agree with `origin` on every release, so a recurrence is caught rather than discovered.
-
-### `--force-with-lease` is not a divergence guard
-
-The lease compares against `refs/remotes/github/main`, and a fresh clone establishes that ref at whatever the remote currently holds. So the lease passes precisely when a fresh clone is used — which is always, in CI. It protects against a stale local view, not against clobbering public work.
-
-The real guard is an explicit content assertion. It is kept alongside the lease, not instead of it.
-
-### The ancestry check must unshallow first
-
-`.git/shallow` can contain the mirror tip, in which case `git merge-base --is-ancestor` answers from a one-commit graph and **fails closed** — reporting "not an ancestor" for commits that genuinely are ancestors. An assertion that always fails is worse than none, because operators learn to bypass it.
-
-The publish path unshallows before reasoning about history.
-
-### Behind is not divergent
-
-The mirror's commits are amended versions of internal commits: different SHAs, identical content, by design. So a commit-identity comparison reports every mirror commit as divergent even when the mirror is perfectly reconciled.
-
-The assertion compares **content**, and excludes deliberate removals (the internal-only path filter, plus files deleted by an internal commit). Being behind is a mirror's normal state and proceeds. Only content the mirror holds and the internal line lacks stops the publish — and the failure names it, because the mirror accepts pull requests and a force-push can destroy work that did not originate internally.
-
-## Files
-
-| File | Role |
-|------|------|
-| `publish-github.sh` | The single implementation: floor resolution, cumulative notes, verbatim tag object, filtered tree, divergence assertion, dry run |
-| `../../infra/scripts/release.sh` | The whole internal release act: derive, assert, notes, tag, push, Release object (stage 1) |
-| `../../infra/scripts/gitlab-release.sh` | Creates the GitLab Release object for a tag; idempotent (stage 1) |
-| `../../.github/workflows/release.yml` | Reads the tag annotation and creates the GitHub Release |
-
-## Known limitation: tag pushes do not trigger the customer pipeline
-
-The generated customer CodePipeline triggers on branch references only — `referenceType: [branch]` in both `infra/cfn/codepipeline/codepipeline.yml` and `infra/tf/codepipeline/main.tf`. Tag pushes are inert.
-
-This is documented rather than worked around: changing it would touch a deployed CloudFormation template, its Terraform twin, the stack skill, and CloudFormation/Terraform parity, to enable a trigger nothing currently asks for.
diff --git a/internal/release/publish-github.sh b/internal/release/publish-github.sh
deleted file mode 100755
index 53c35c0..0000000
--- a/internal/release/publish-github.sh
+++ /dev/null
@@ -1,452 +0,0 @@
-#!/usr/bin/env bash
-# Publish a chosen internal tag to the public GitHub mirror, with release notes
-# covering every change since the last GitHub publish.
-#
-# Usage:
-# internal/release/publish-github.sh [--dry-run] [TAG]
-# TAG=v0.4.0 internal/release/publish-github.sh --dry-run
-#
-# Stage 2 of a two-stage release. Stage 1 (the `release` GitLab job) cuts an
-# internal tag and publishes nothing publicly; this publishes a chosen tag —
-# including an older one — to GitHub.
-#
-# Publishing withholds only TAGS and RELEASE OBJECTS, never code: the mirror
-# amends the tip and force-pushes main, so a skipped release's code lands
-# publicly anyway. That is what makes cumulative notes correctness rather than
-# courtesy — a gap in the public tag sequence describes real published code that
-# no Release object currently documents.
-#
-# Four values are computed, each with a subtlety that breaks it if missed:
-#
-# TAG newest strict-semver tag on origin, or an explicit override.
-# Must filter to strict semver or a stray v0.9.9-rc1 wins.
-# FLOOR newest strict-semver tag on github. Needs sort -V; plain sort ranks
-# v0.1.2 above v0.1.10 (verified).
-# NOTES git-cliff FLOOR..TAG --strip all. GitHub supplies the floor's NAME;
-# the range must resolve against origin's commit for that name, because
-# GitHub's tag objects point at amended commits.
-# tree EXCLUDE_PATHS applied in a throwaway worktree. The old
-# github-push.sh ran `git tag -f` in the operator's clone, which is
-# what corrupted local v0.1.7.
-
-set -euo pipefail
-
-DRY_RUN=false
-ARGS=()
-for arg in "$@"; do
- case "$arg" in
- --dry-run) DRY_RUN=true ;;
- -*) echo "error: unknown flag: $arg" >&2; exit 1 ;;
- *) ARGS+=("$arg") ;;
- esac
-done
-
-# AN UNSET CONFIRM_TAG *IS* THE DRY RUN.
-#
-# Publishing force-pushes a public repository that accepts pull requests, so the
-# confirmation stays. Only the dry-run TRIGGER changes: the separate plan job
-# existed solely to pass --dry-run, and a missing confirmation already expressed
-# the same intent.
-#
-# The two conditions compose in the safe direction — either alone yields a dry
-# run, and only a matching CONFIRM_TAG with no --dry-run publishes. The explicit
-# flag is retained rather than removed as redundant, so a dry run can still be
-# requested locally when CONFIRM_TAG is already exported.
-#
-# The CONFIRM_TAG != TAG mismatch check further down is NOT replaced by this. It
-# now guards a narrower case — a confirmation naming the WRONG tag, usually a
-# stale value from a previous run whose derived tag has since moved — which is
-# exactly the case worth catching.
-if [ -z "${CONFIRM_TAG:-}" ]; then
- DRY_RUN=true
-fi
-
-GITHUB_REMOTE="github"
-GITHUB_REPO="git@github.com:aws-samples/sample-innovation-patterns.git"
-ORIGIN_REMOTE="origin"
-SEMVER_RE='^v[0-9]+\.[0-9]+\.[0-9]+$'
-
-REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
-
-# ---------------------------------------------------------------------------
-# Internal-only paths
-# ---------------------------------------------------------------------------
-
-# Paths that stay in GitLab but must never ship to GitHub.
-EXCLUDE_PATHS=(
- ".gitlab-ci.yml"
- ".gitlab"
- ".specify"
- "docs/docs/developer-docs/internal"
- "docs/docs/guides/releasing.md"
- "scripts/.gitignore"
- "internal"
-)
-
-# Asserted below. A quoting or separator defect that merges two entries into one
-# silently reduces the number of paths filtered, so the count is checked rather
-# than assumed. Update this when adding an entry above.
-EXPECTED_EXCLUDE_COUNT=7
-
-# Entries deliberately absent from the tracked tree. Everything NOT listed here
-# must have been tracked in HEAD before the removal loop — otherwise a typo is
-# indistinguishable from a successful removal, since both leave the path absent.
-#
-# .specify on disk but git-ignored. Excluded as
-# belt-and-braces in case it is ever un-ignored.
-# docs/docs/guides/releasing.md has NEVER existed on disk. Retained to reserve
-# the name: a guide written at this exact path
-# would be silently stripped from the public
-# tree. Write builder release docs elsewhere.
-INTENTIONALLY_ABSENT=(
- ".specify"
- "docs/docs/guides/releasing.md"
-)
-
-is_intentionally_absent() {
- local needle="$1" entry
- for entry in "${INTENTIONALLY_ABSENT[@]}"; do
- [ "$entry" = "$needle" ] && return 0
- done
- return 1
-}
-
-if [ "${#EXCLUDE_PATHS[@]}" -ne "$EXPECTED_EXCLUDE_COUNT" ]; then
- echo "error: EXCLUDE_PATHS holds ${#EXCLUDE_PATHS[@]} elements, expected $EXPECTED_EXCLUDE_COUNT" >&2
- echo "hint: a missing separator merges two entries into one, silently reducing" >&2
- echo " what is filtered. Check quoting, or update EXPECTED_EXCLUDE_COUNT" >&2
- echo " if an entry was added deliberately." >&2
- exit 1
-fi
-
-# ---------------------------------------------------------------------------
-# Tag and floor resolution
-# ---------------------------------------------------------------------------
-
-remote_semver_tags() {
- git ls-remote --tags "$1" 2>/dev/null \
- | awk '{print $2}' \
- | sed 's|^refs/tags/||' \
- | grep -E "$SEMVER_RE" \
- | sort -V
-}
-
-git remote add "$GITHUB_REMOTE" "$GITHUB_REPO" 2>/dev/null || true
-
-TAG="${ARGS[0]:-${TAG:-}}"
-if [ -z "$TAG" ]; then
- TAG="$(remote_semver_tags "$ORIGIN_REMOTE" | tail -1)"
- if [ -z "$TAG" ]; then
- echo "error: no strict-semver tag found on $ORIGIN_REMOTE" >&2
- exit 1
- fi
- echo "TAG not supplied — defaulting to the newest tag on $ORIGIN_REMOTE"
-fi
-
-if ! remote_semver_tags "$ORIGIN_REMOTE" | grep -qx "$TAG"; then
- echo "error: $TAG is not a strict-semver tag on $ORIGIN_REMOTE" >&2
- echo "hint: available tags:" >&2
- remote_semver_tags "$ORIGIN_REMOTE" | sed 's/^/ /' >&2
- exit 1
-fi
-
-# GitHub is the only authority on what GitHub already has. A marker file would be
-# a second source of truth that can drift, and drift here means either
-# republishing notes or silently dropping a version's worth of them.
-FLOOR="$(remote_semver_tags "$GITHUB_REMOTE" | tail -1 || true)"
-
-echo "TAG: $TAG"
-if [ -n "$FLOOR" ]; then
- echo "FLOOR: $FLOOR (newest tag already on $GITHUB_REMOTE)"
-else
- echo "FLOOR: none — no semver tag on $GITHUB_REMOTE, notes will span full history"
-fi
-
-# The window must run forward. Publishing a tag at or below the floor would
-# produce a backwards range, which git-cliff answers with an "Unreleased"
-# section rather than an error — a plausible-looking body describing nothing.
-#
-# Note this is not the same as "publishing an older tag is unsupported": an
-# older tag publishes fine as long as it is newer than what GitHub already has.
-# What cannot be done is publishing BEHIND the public mirror, because the notes
-# window for that is empty by definition and the mirror is already ahead.
-if [ -n "$FLOOR" ] && [ "$TAG" = "$FLOOR" ]; then
- echo "error: $TAG is already the newest tag on $GITHUB_REMOTE — nothing to publish" >&2
- exit 1
-fi
-
-if [ -n "$FLOOR" ] && [ "$(printf '%s\n%s\n' "$TAG" "$FLOOR" | sort -V | tail -1)" = "$FLOOR" ]; then
- echo "error: $TAG is older than the newest tag already on $GITHUB_REMOTE ($FLOOR)" >&2
- echo "hint: the public mirror is already ahead of $TAG, so there is nothing since" >&2
- echo " the last publish to describe. Publish a tag newer than $FLOOR." >&2
- exit 1
-fi
-
-# Make sure the local objects for both endpoints exist and agree with origin.
-git fetch --quiet "$ORIGIN_REMOTE" --tags --force
-
-# ---------------------------------------------------------------------------
-# Cumulative notes
-# ---------------------------------------------------------------------------
-
-NOTES_FILE="$(mktemp)"
-trap 'rm -f "$NOTES_FILE"' EXIT
-
-# FLOOR supplies the NAME from GitHub; the range resolves against origin's commit
-# for that name. GitHub's tag objects point at amended commits, so resolving the
-# range there would span the wrong window or fail outright.
-if [ -n "$FLOOR" ]; then
- if ! git rev-parse --verify --quiet "refs/tags/$FLOOR" >/dev/null; then
- echo "error: floor tag $FLOOR exists on $GITHUB_REMOTE but not locally" >&2
- echo "hint: git fetch $ORIGIN_REMOTE --tags --force" >&2
- exit 1
- fi
- git-cliff "refs/tags/$FLOOR".."refs/tags/$TAG" --strip all > "$NOTES_FILE"
-else
- git-cliff --tag "$TAG" --strip all > "$NOTES_FILE"
-fi
-
-if [ ! -s "$NOTES_FILE" ]; then
- echo "error: generated notes are empty for $FLOOR..$TAG" >&2
- echo "hint: is $TAG newer than $FLOOR? Nothing to publish otherwise." >&2
- exit 1
-fi
-
-echo
-echo "--- release notes ($FLOOR..$TAG, exclusive of the floor) ---"
-cat "$NOTES_FILE"
-echo "--- end release notes ---"
-echo
-
-# ---------------------------------------------------------------------------
-# Divergence assertion
-# ---------------------------------------------------------------------------
-
-# Two things are load-bearing here.
-#
-# UNSHALLOW FIRST. .git/shallow may contain the mirror tip, in which case
-# ancestry questions are answered from a truncated graph and FAIL CLOSED —
-# reporting "not an ancestor" for commits that genuinely are ancestors. An
-# assertion that always fails is worse than none: operators learn to bypass it.
-#
-# ASSERT ON CONTENT, NOT COMMIT IDENTITY. The mirror's commits are AMENDED
-# versions of internal commits — different SHAs for identical content, by design.
-# So "is this public commit an ancestor?" answers no for every mirror commit even
-# when the mirror is perfectly reconciled. The real question is whether the
-# mirror holds content the internal line lacks.
-#
-# --force-with-lease is NOT the guard: a fresh CI clone establishes its
-# remote-tracking ref at whatever the remote holds, so the lease passes precisely
-# when a fresh clone is used, which is always in CI.
-assert_no_divergence() {
- if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
- echo "unshallowing before reasoning about ancestry..."
- git fetch --quiet --unshallow "$ORIGIN_REMOTE" 2>/dev/null || git fetch --quiet --unshallow || true
- fi
-
- if ! git fetch --quiet "$GITHUB_REMOTE" main 2>/dev/null; then
- echo "note: $GITHUB_REMOTE/main not fetchable (likely first publish) — skipping divergence check"
- return 0
- fi
-
- # Paths the mirror has that the internal line does not.
- #
- # Deliberate removals must be excluded or this fires on every run: EXCLUDE_PATHS
- # is stripped on every publish, and files removed by an internal commit are
- # absent internally by design. Both are "the mirror is behind", not divergence.
- # An assertion that always fails teaches operators to bypass it, so the
- # filtering here is the difference between a real guard and a nuisance.
- local candidates orphans=""
- candidates="$(comm -23 \
- <(git ls-tree -r FETCH_HEAD --name-only | sort) \
- <(git ls-tree -r HEAD --name-only | sort) || true)"
-
- local path excluded prefix
- while IFS= read -r path; do
- [ -z "$path" ] && continue
- excluded=false
- for prefix in "${EXCLUDE_PATHS[@]}"; do
- # Match the entry itself or anything beneath it.
- if [ "$path" = "$prefix" ] || [ "${path#"$prefix"/}" != "$path" ]; then
- excluded=true
- break
- fi
- done
- # Deliberately removed on the internal line: present in the mirror's tree but
- # deleted by an internal commit rather than never having existed.
- if [ "$excluded" = false ] && git log --oneline -1 --diff-filter=D -- "$path" | grep -q .; then
- excluded=true
- fi
- [ "$excluded" = false ] && orphans="${orphans}${path}"$'\n'
- done <<< "$candidates"
-
- orphans="$(printf '%s' "$orphans" | sed '/^$/d')"
-
- if [ -n "$orphans" ]; then
- echo "error: $GITHUB_REMOTE/main holds content absent from the internal line" >&2
- echo "$orphans" | sed 's/^/ /' >&2
- echo "hint: the public mirror accepts pull requests, so a force-push can destroy" >&2
- echo " work that did not originate internally. Reconcile these paths onto" >&2
- echo " the internal line first, or confirm they were deliberately removed." >&2
- return 1
- fi
-
- echo "ok: $GITHUB_REMOTE/main is behind, not divergent — safe to publish"
-}
-
-assert_no_divergence
-
-# ---------------------------------------------------------------------------
-# Filtered tree + notes-carrying retag, in a THROWAWAY WORKTREE
-# ---------------------------------------------------------------------------
-
-# The operator's clone is never mutated. github-push.sh's `git tag -f` ran in the
-# invoking clone and is what corrupted local v0.1.7; a worktree gives the amend
-# and retag their own index, HEAD, and checkout, so local tags are untouched by
-# construction rather than by care.
-WORKTREE_DIR="$(mktemp -d)/publish"
-cleanup() {
- rm -f "$NOTES_FILE"
- if [ -n "${WORKTREE_DIR:-}" ] && [ -d "$WORKTREE_DIR" ]; then
- git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || true
- fi
- git worktree prune 2>/dev/null || true
-}
-trap cleanup EXIT
-
-git worktree add --quiet --detach "$WORKTREE_DIR" "refs/tags/$TAG"
-cd "$WORKTREE_DIR"
-
-git config user.email "ci@code.aws.dev"
-git config user.name "GitLab CI"
-
-# Snapshot tracking state BEFORE the removal loop. Afterwards every entry is
-# absent, so "removed" and "never present" are indistinguishable.
-TRACKED_BEFORE=()
-for path in "${EXCLUDE_PATHS[@]}"; do
- if git ls-files --error-unmatch "$path" &>/dev/null; then
- TRACKED_BEFORE+=("$path")
- fi
-done
-
-was_tracked_before() {
- local needle="$1" entry
- for entry in "${TRACKED_BEFORE[@]+"${TRACKED_BEFORE[@]}"}"; do
- [ "$entry" = "$needle" ] && return 0
- done
- return 1
-}
-
-for path in "${EXCLUDE_PATHS[@]}"; do
- if git ls-files --error-unmatch "$path" &>/dev/null; then
- git rm -rq "$path"
- fi
-done
-
-# Defensively strip any generated artifacts that may have slipped into git.
-# These match scripts/.gitignore: top-level scripts/*.mk except test.mk, and
-# top-level scripts/*.md except INSTALL-RUNBOOK.md. infra/scripts/ is untouched.
-if [ -d scripts ]; then
- while IFS= read -r -d '' path; do
- git ls-files --error-unmatch "$path" &>/dev/null && git rm -qf "$path"
- done < <(find scripts -maxdepth 1 -type f \( -name '*.mk' ! -name 'test.mk' \) -print0)
-
- while IFS= read -r -d '' path; do
- git ls-files --error-unmatch "$path" &>/dev/null && git rm -qf "$path"
- done < <(find scripts -maxdepth 1 -type f \( -name '*.md' ! -name 'INSTALL-RUNBOOK.md' \) -print0)
-fi
-
-git commit --quiet --amend --no-edit
-AMENDED_COMMIT="$(git rev-parse HEAD)"
-
-# Build the annotated tag object for the amended commit, carrying the notes.
-#
-# `git tag` is deliberately NOT used: a worktree shares refs/tags with the main
-# repository, so `git tag -f` here would move the OPERATOR'S tag — the exact
-# mechanism that corrupted local v0.1.7. Worktrees isolate the index, HEAD, and
-# checkout; they do NOT isolate refs. `git mktag` writes the object into the
-# object store and writes no ref at all, so nothing in the operator's clone
-# changes and the object can still be pushed by SHA.
-#
-# THE CLEANUP MODE IS LOAD-BEARING. `git tag -a -m "$NOTES"` destroys every ##
-# and ### heading via git's default --cleanup=strip, EXITS 0, and warns nobody —
-# a release published that way carries a flat, ungrouped, unversioned bullet
-# list. mktag is verbatim by construction: it stores the body byte-for-byte.
-TAG_OBJECT="$(
- {
- echo "object $AMENDED_COMMIT"
- echo "type commit"
- echo "tag ${TAG#refs/tags/}"
- echo "tagger $(git config user.name) <$(git config user.email)> $(git log -1 --format=%ct HEAD) +0000"
- echo
- cat "$NOTES_FILE"
- } | git mktag
-)"
-
-# Assert rather than trust: the flattening failure mode is invisible otherwise.
-if ! git cat-file tag "$TAG_OBJECT" | grep -q '^## \['; then
- echo "error: tag annotation lost its heading structure" >&2
- echo "hint: the notes body must be stored verbatim — git strips #-leading lines" >&2
- echo " when a message goes through --cleanup=strip (the default for -m)" >&2
- exit 1
-fi
-echo "ok: tag annotation preserves its heading structure"
-
-# ---------------------------------------------------------------------------
-# Report or publish
-# ---------------------------------------------------------------------------
-
-if [ "$DRY_RUN" = true ]; then
- echo
- echo "--- filtered paths ---"
- for path in "${EXCLUDE_PATHS[@]}"; do
- if git ls-files --error-unmatch "$path" &>/dev/null 2>&1; then
- echo "FAIL: $path still present" >&2
- exit 1
- fi
- if was_tracked_before "$path"; then
- echo "OK: $path removed"
- elif is_intentionally_absent "$path"; then
- echo "OK: $path absent (declared intentionally absent)"
- else
- echo "FAIL: $path was never tracked in HEAD — nothing was removed" >&2
- echo "hint: this is indistinguishable from a typo. Fix the path, or add it" >&2
- echo " to INTENTIONALLY_ABSENT with a reason if the absence is deliberate." >&2
- exit 1
- fi
- done
- echo "--- end filtered paths ---"
- echo
- echo "dry-run: nothing was pushed."
- echo " Everything above is what a publish would do. To actually publish,"
- echo " re-run the 'github:release' job with the confirmation below."
- echo
- # A bare, greppable, copy-pasteable line, matching the shape release.sh uses
- # for CONFIRM_VERSION so the two confirmations read the same way. They stay
- # DISTINCT variables: conflating them would let a release confirmation
- # authorise a publish.
- echo "hint: re-run with CONFIRM_TAG=$TAG"
-else
- if [ "${CONFIRM_TAG:-}" != "$TAG" ]; then
- echo "error: publishing to the public mirror requires explicit confirmation" >&2
- echo "would publish: $TAG" >&2
- echo "hint: re-run with CONFIRM_TAG=$TAG" >&2
- exit 1
- fi
-
- if git fetch --quiet "$GITHUB_REMOTE" main 2>/dev/null; then
- git push --quiet "$GITHUB_REMOTE" "$AMENDED_COMMIT:refs/heads/main" --force-with-lease
- else
- echo "$GITHUB_REMOTE:main not fetchable (likely first release) — using --force"
- git push --quiet "$GITHUB_REMOTE" "$AMENDED_COMMIT:refs/heads/main" --force
- fi
- # Push the tag OBJECT by SHA. No local ref named $TAG was created or moved, so
- # the operator's clone is untouched.
- git push --quiet --force "$GITHUB_REMOTE" "$TAG_OBJECT:refs/tags/$TAG"
-
- echo "ok: published $TAG to $GITHUB_REMOTE (filtered ${#EXCLUDE_PATHS[@]} internal paths)"
- echo " the GitHub Release is created by .github/workflows/release.yml from the tag annotation"
-fi
-
-cd "$REPO_ROOT"
diff --git a/internal/release/templates/README.md b/internal/release/templates/README.md
deleted file mode 100644
index c9418c8..0000000
--- a/internal/release/templates/README.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# Portable Release Standard
-
-A release practice you can adopt in any git repository. It needs `make`, `git`, and [`git-cliff`](https://git-cliff.org) — no CI system, no particular forge, and no framework.
-
-## Adopt it
-
-```bash
-cp cliff.toml /path/to/your/project/
-cp release.mk /path/to/your/project/
-brew install git-cliff # or see https://git-cliff.org
-```
-
-Then:
-
-```bash
-make -f release.mk release-preview # what would be released; creates nothing
-make -f release.mk release # changelog + annotated tag (+ forge, if any)
-```
-
-Nothing in either file names a specific project, so there are no values to substitute.
-
-## What each file does
-
-| File | Role |
-|------|------|
-| `cliff.toml` | Maps Conventional Commit types to changelog sections, and controls how the next version is derived |
-| `release.mk` | The targets: `release-preview`, `release-changelog`, `release-tag`, `release-forge`, `release` |
-
-## The idea
-
-**The version is derived, never stored.** A `VERSION` file conflates two different questions — "what is the next version?" (a release-time derivation from history) and "what is the current version?" (a build-time display) — and, being hand-maintained, drifts from the tags that actually define releases. `git-cliff --bumped-version` answers the first; `git describe` answers the second.
-
-**Commit messages are the input.** A `fix:` commit derives a patch bump, `feat:` a minor. A commit matching no conventional type derives nothing and appears in no changelog section — so the format is not a style preference, it is the thing that determines whether work is recorded.
-
-**The notes ride on the annotated tag.** That makes the tag self-describing: any clone can read the release notes for a version without a changelog file, a forge API, or network access.
-
-## What you must decide for yourself
-
-1. **Pre-1.0 behavior.** `cliff.toml` ships with `breaking_always_bump_major = false`, so a `feat!:` commit derives `0.x+1` rather than `1.0.0` — reaching a major becomes a deliberate act (`git-cliff --bump major`). This is **inert** once your major version reaches 1: after that, a breaking change derives the next major normally. Delete it if you want `feat!:` to mean `1.0.0` immediately.
-
-2. **The `^Update:` skip parser.** Delete it in a new project. It exists as the pattern to copy when you inherit a repository whose history predates the convention: one skip parser neutralizes the changelog symptom without rewriting history.
-
-3. **Whether to commit `CHANGELOG.md`.** The `release` target writes it and leaves it uncommitted for review. Because the notes also travel on the tag, a committed changelog is a convenience rather than a requirement — which means you can skip the CI commit-back machinery (protected-branch push rights, `[skip ci]` loop guards) entirely.
-
-4. **Compare links.** Deliberately not generated. If your published tags are ever a subset of your internal tags, a compare link between two adjacent changelog versions is a 404 by construction. Tags and forge Release objects are the durable record. Add a `footer` to `cliff.toml` if you publish every tag and want them.
-
-## Behavior when there is no forge
-
-`release-forge` **skips rather than fails**:
-
-- No `origin` remote → the tag is created locally and you are told to push it yourself later.
-- Remote but no `gh`/`glab` CLI → the tag is pushed; no Release object is created, and you are told the annotation already carries the notes so a Release can be made later.
-- `NO_PUSH=1` → nothing is pushed.
-
-`make -f release.mk release` therefore succeeds in a repository with no remote at all. A release target that failed in that case would be unusable rather than merely limited.
-
-## The one hazard worth knowing
-
-`git tag -a -m "$NOTES"` **destroys every Markdown heading** in the notes. Git's default `--cleanup=strip` treats `#`-leading lines as comments, deletes them, **exits 0**, and warns nobody — so the tag looks fine and carries a flat, ungrouped, unversioned bullet list.
-
-`release.mk` writes the annotation with `--cleanup=verbatim -F ` and then asserts the headings survived, deleting the tag if they did not. Keep both halves if you adapt it: the assertion is what makes the failure visible.
-
-Reading the body from a file rather than a shell variable also avoids quoting problems with the backticks and asterisks that appear in real release notes.
diff --git a/internal/release/templates/cliff.toml b/internal/release/templates/cliff.toml
deleted file mode 100644
index bcdbd62..0000000
--- a/internal/release/templates/cliff.toml
+++ /dev/null
@@ -1,77 +0,0 @@
-[changelog]
-header = """# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-"""
-
-body = """
-{% if version -%}
-## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
-{% else -%}
-## [Unreleased]
-{% endif -%}
-{% for group, commits in commits | group_by(attribute="group") %}
-### {{ group | upper_first }}
-{% for commit in commits -%}
-- {% if commit.scope %}**{{ commit.scope }}** — {% endif %}\
-{{ commit.message | split(pat="\n") | first | upper_first | trim }}
-{% endfor %}
-{% endfor -%}
-"""
-
-# No footer. Per-version compare links are deliberately not generated:
-#
-# 1. Under selective publication, gaps in the public tag sequence are routine,
-# so a link between two versions where only one was published is a 404 by
-# construction. This repository already published one (v0.1.5).
-# 2. Hardcoding a forge URL here makes any copy of this config emit links to
-# the original project's repository.
-#
-# Tags and forge Release objects are the durable, navigable record.
-
-trim = true
-
-[git]
-conventional_commits = true
-filter_unconventional = false
-split_commits = false
-sort_commits = "oldest"
-# Strict semver only. A permissive pattern lets a stray prerelease-shaped tag
-# (v0.9.9-rc1) become the derivation base — verified.
-tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+$"
-protect_breaking_commits = false
-commit_parsers = [
- # OPTIONAL — delete this line in a new project.
- #
- # It exists because the repository this template came from had a run of
- # auto-generated "Update: modify N file(s)" commits that would otherwise fill
- # the changelog with diffstat noise. A fresh project has no such history.
- #
- # Kept here as the pattern to copy when you inherit a repository whose
- # history predates the convention: one skip parser neutralizes the changelog
- # symptom without rewriting history.
- { message = "^Update:", skip = true },
- { message = "^feat", group = "Added" },
- { message = "^fix", group = "Fixed" },
- { message = "^docs", group = "Documentation" },
- { message = "^perf", group = "Performance" },
- { message = "^refactor", group = "Changed" },
- { message = "^ci", group = "CI/Build" },
- { message = "^build", group = "CI/Build" },
- { message = "^revert", group = "Reverted" },
- { message = "^style", skip = true },
- { message = "^test", skip = true },
- { message = "^chore", skip = true },
-]
-
-[bump]
-# Pre-1.0 guard: a `feat!:` commit yields 0.x+1 rather than 1.0.0. Reaching 1.0.0
-# must be a deliberate, explicitly requested major bump (`git-cliff --bump major`),
-# not something ordinary derivation can stumble into.
-#
-# INERT once the major is >= 1 — verified: at v1.0.0 a `feat!:` still derives
-# v2.0.0. This is pre-1.0 scaffolding, not a permanent invariant.
-breaking_always_bump_major = false
diff --git a/internal/release/templates/release.mk b/internal/release/templates/release.mk
deleted file mode 100644
index e22e488..0000000
--- a/internal/release/templates/release.mk
+++ /dev/null
@@ -1,117 +0,0 @@
-# Release automation — forge-agnostic reference implementation.
-#
-# Works with `make`, `git`, and `git-cliff` alone. No CI system, no forge, and no
-# framework required. Any forge-specific step is SKIPPED rather than failed when
-# the forge is absent, so this works in a repository with no remote at all.
-#
-# Usage:
-# make -f release.mk release-preview # what would be released; creates nothing
-# make -f release.mk release # changelog + annotated tag
-# make -f release.mk release VERSION=1.2.3 # explicit override
-#
-# Requires: git-cliff (https://git-cliff.org) — `brew install git-cliff`
-
-.PHONY: release release-preview release-changelog release-tag release-forge
-
-# The version is DERIVED from Conventional Commit history, not stored in a file.
-# A version file conflates two different questions — "what is next?" (a
-# release-time derivation) and "what is current?" (a build-time display) — and
-# being hand-maintained, it drifts from the tags that actually define releases.
-VERSION ?= $(shell git-cliff --bumped-version 2>/dev/null | sed 's/^v//')
-TAG = v$(VERSION)
-LATEST_TAG = $(shell git describe --tags --abbrev=0 2>/dev/null)
-
-# Where the notes body is staged. Kept in a file rather than a shell variable:
-# the content is multi-line and contains backticks and asterisks that shell
-# quoting mangles.
-NOTES_FILE ?= .release-notes.md
-
-define require_releasable
- @command -v git-cliff >/dev/null 2>&1 || { \
- echo "error: git-cliff is not installed" >&2; \
- echo "hint: brew install git-cliff (or see https://git-cliff.org)" >&2; \
- exit 1; \
- }
- @if [ -z "$(VERSION)" ]; then \
- echo "error: could not derive a version from commit history" >&2; \
- echo "hint: are there any commits? Is cliff.toml present?" >&2; \
- exit 1; \
- fi
- @if [ "$(TAG)" = "$(LATEST_TAG)" ]; then \
- echo "Nothing to release — derived version equals the current tag ($(LATEST_TAG))."; \
- echo "Only conventional commits (feat:, fix:, ...) derive a new version."; \
- exit 1; \
- fi
-endef
-
-# Print what a release would do. Creates nothing, so it is safe to run always.
-release-preview:
- @echo "current release: $(if $(LATEST_TAG),$(LATEST_TAG),none)"
- @echo "would release: $(TAG)"
- @if [ "$(TAG)" = "$(LATEST_TAG)" ]; then \
- echo ""; \
- echo "Nothing to release — no commits since $(LATEST_TAG) affect the version."; \
- else \
- echo ""; \
- echo "--- release notes for $(TAG) ---"; \
- git-cliff --unreleased --strip all; \
- echo "--- end release notes ---"; \
- fi
-
-# Regenerate the committed changelog through the derived version.
-release-changelog:
- $(call require_releasable)
- @git-cliff --tag "$(TAG)" -o CHANGELOG.md
- @echo "ok: wrote CHANGELOG.md through $(TAG)"
-
-# Create the annotated tag, carrying the release notes on the annotation.
-release-tag:
- $(call require_releasable)
-# --tag stamps the heading as "## [X.Y.Z] - ". Without it the notes are
-# headed "## [Unreleased]", which is wrong on a tag that names a version.
- @git-cliff --unreleased --tag "$(TAG)" --strip all > $(NOTES_FILE)
- @if [ ! -s $(NOTES_FILE) ]; then \
- echo "error: generated notes are empty" >&2; rm -f $(NOTES_FILE); exit 1; \
- fi
-# --cleanup=verbatim is LOAD-BEARING. Without it, git's default --cleanup=strip
-# treats every #-leading line as a comment and silently deletes all Markdown
-# headings from the annotation. It exits 0 and warns nobody.
- @git tag -a "$(TAG)" --cleanup=verbatim -F $(NOTES_FILE)
-# Assert rather than trust: the failure above is otherwise invisible.
- @git tag -l --format='%(contents)' "$(TAG)" | grep -q '^## \[' || { \
- echo "error: tag annotation lost its heading structure" >&2; \
- echo "hint: --cleanup=verbatim is required" >&2; \
- git tag -d "$(TAG)" >/dev/null; rm -f $(NOTES_FILE); exit 1; \
- }
- @rm -f $(NOTES_FILE)
- @echo "ok: created annotated tag $(TAG)"
-
-# Push the tag and create a forge Release, IF a forge is configured.
-#
-# SKIPPED, not failed, when absent: a solution may have no forge of any kind, and
-# a release target that fails in that case is unusable rather than merely limited.
-release-forge:
- @if ! git remote get-url origin >/dev/null 2>&1; then \
- echo "skip: no 'origin' remote configured — tag created locally only"; \
- echo " (push it yourself when a remote exists: git push $(TAG))"; \
- elif [ -n "$(NO_PUSH)" ]; then \
- echo "skip: NO_PUSH set — tag created locally only"; \
- else \
- git push origin "$(TAG)" && echo "ok: pushed $(TAG) to origin"; \
- if command -v gh >/dev/null 2>&1 && gh repo view >/dev/null 2>&1; then \
- gh release create "$(TAG)" --notes "$$(git tag -l --format='%(contents)' $(TAG))" \
- && echo "ok: created GitHub Release $(TAG)"; \
- elif command -v glab >/dev/null 2>&1 && glab repo view >/dev/null 2>&1; then \
- glab release create "$(TAG)" --notes "$$(git tag -l --format='%(contents)' $(TAG))" \
- && echo "ok: created GitLab Release $(TAG)"; \
- else \
- echo "skip: no forge CLI available (gh/glab) — tag pushed, no Release object"; \
- echo " the annotated tag carries the notes, so a Release can be made later"; \
- fi; \
- fi
-
-# The whole flow. Changelog and tag always; forge steps only if a forge exists.
-release: release-changelog release-tag release-forge
- @echo ""
- @echo "Released $(TAG)."
- @echo "The CHANGELOG.md change is uncommitted — review and commit it."
diff --git a/scripts/.gitignore b/scripts/.gitignore
deleted file mode 100644
index 8855864..0000000
--- a/scripts/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# IPA generated Makefiles & security disposition (solution-specific, not committed)
-*.mk
-SECURITY-DISPOSITION.md
-README.md