# ACDL — v1.1 Research Findings > Phase: research (pre-Phase 06). Milestone: v1.1. Status: complete. > Researcher: ci-researcher. Autonomy: full (CLARIFY auto-resolved parameters). > Sources: web (Gitea docs, act_runner/gitea-runner repo, gitea/runner-images, > Checkov docs, Checkov GitHub README, go-gitea issue tracker) + ACDL codebase. > This file is a research artifact; no implementation code here, only sketches / > snippets / schema shapes. Decisions surfaced are listed in §"Decisions > surfaced" and are *proposals* for PROJECT.md until lead-developer adopts them. --- ## TARGET 1 — Gitea / act_runner OIDC support (HIGHEST PRIORITY) ### Findings **Verdict: Gitea Actions does NOT support emitting an OIDC `id-token` as of Gitea 1.27.x / gitea-runner (formerly act_runner) v2.1.0 (July 2026).** This single finding gates Phase 08 feasibility. Evidence (all verified 2026-07-21): 1. **Gitea docs — "Compared to GitHub Actions"** (https://docs.gitea.com/usage/actions/comparison), section "Different behavior → Job token permissions (`permissions`)": > "GitHub-only scopes such as `statuses`, `checks`, `deployments`, > `id-token`, `security-events`, and `pages` are not supported, while > Gitea-specific scopes such as `code`, `releases`, `wiki`, and `projects` > are available." `id-token` is explicitly listed as an unsupported GitHub-only scope. The `permissions: id-token: write` block that the GitHub Actions AWS OIDC pattern relies on is therefore a no-op in Gitea Actions. 2. **Gitea docs — "Actions job token permissions (GITEA_TOKEN)"** (https://docs.gitea.com/usage/actions/token-permissions), "Compatibility notes": identical text — `id-token` not supported. The supported scopes are `contents, code, releases, issues, pull-requests, actions, wiki, projects, packages`. 3. **Open issue: "Native OIDC Token for workload identity federation"** (go-gitea/gitea#33681, opened 2025-02-21, status **Open** as of 2026-07-21, label `type/proposal`). The issue author explicitly asks for `permissions.id-token: write`–style issuance so Gitea CI can federate to GCP/AWS without long-lived keys. No milestone assigned. Confirms the gap is still open at the proposal stage. 4. **Open issue: "Gitea as an OIDC IdP for Actions"** (go-gitea/gitea#26383, opened 2023-08-07, status **Open**). Long-standing request; the original motivation for the OIDC work. 5. **Draft PR: "Add Actions OIDC provider with workflow permission gating"** (go-gitea/gitea#36988, opened 2026-03-25 by @lunny, status **Draft** as of 2026-07-21, label `lgtm/need 2` — needs two maintainer approvals, has unresolved review comments from 2026-05-27 re: case-insensitive `bearer` header, `.well-known` path location, and run_id/job_id query-param design). The PR implements the `ACTIONS_ID_TOKEN_REQUEST_URL` / `ACTIONS_ID_TOKEN_REQUEST_TOKEN` env-var contract, `id-token` permission parsing, and a JWT issuer — but it is **not merged** and not in any released Gitea version. No 1.27.x or 1.28-dev changelog mentions it. 6. **Open issue: "ci: consider replacing AWS access-key secrets with OIDC"** (go-gitea/gitea#37980, opened 2026-06-03, status **Open**) — Gitea's *own* CI is still using long-lived AWS access keys because OIDC is not available. This is the strongest possible signal: the Gitea project itself has not been able to dogfood OIDC. 7. **Runner rename:** `act_runner` was renamed to `gitea-runner` in gitea/runner#850 (2026-04-30). Latest runner release: v2.1.0 (2026-07-16). The v0.2.x line is the legacy `act_runner` naming. No `gitea-runner` release notes mention OIDC token issuance. **Conclusion:** the GitHub Actions pattern (`permissions: id-token: write` → `ACTIONS_ID_TOKEN_REQUEST_URL` + `ACTIONS_ID_TOKEN_REQUEST_TOKEN` → `aws sts assume-role-with-web-identity` via `aws-actions/configure-aws-credentials`) is **not portable to Gitea Actions today**. There is no env-var, no permission flag, and no documented mechanism. The draft PR #36988, if/when merged, would close the gap — but it cannot be a v1.1 dependency (draft, unmerged, no target milestone). ### Confidence 0.95 — multiple primary-source confirmations (official docs + open issues + draft PR state). The 0.05 residual is for the possibility that the Gitea instance at https://git.cloudinit.dev runs a custom build with #36988 cherry-picked; this is unlikely (the instance is documented as standard Gitea per the v1.0 research) and should be verified in Phase 08 by checking `https://git.cloudinit.dev/api/v1/version` and the runner version label. ### Assumptions logged - **A-1.1** (0.90): the Gitea instance at git.cloudinit.dev runs upstream Gitea 1.27.x with no OIDC patches. Verifiable in Phase 08 via `GET /api/v1/version` and the runner admin page. - **A-1.2** (0.85): PR #36988 will not merge and release in time for the v1.1 spike (Phases 08–10). Even if it merged tomorrow, it would land in 1.28-dev at the earliest; the spike cannot block on it. ### Fallbacks evaluated (for assuming an AWS IAM role from a Gitea Actions step without a long-lived key) | Option | Mechanism | Viability for the spike | |--------|-----------|--------------------------| | (a) GitHub-style OIDC (`id-token: write`) | `ACTIONS_ID_TOKEN_REQUEST_URL` → JWT → `sts assume-role-with-web-identity` | **Not available** (see above). | | (b) Self-hosted OIDC broker | Stand up a tiny OIDC IdP (e.g. `dex`, `oauth2-proxy`, or a custom JWKS endpoint) that the Gitea job authenticates to with its `GITEA_TOKEN` and that issues a JWT minted with a platform signing key; AWS IAM trusts the broker's JWKS. | Workable but heavy for a spike — requires a second always-on service, a signing-key rotation story, and IAM trust plumbing. Better suited to v1.2. | | (c) `aws sts assume-role-with-web-identity` with a token from Gitea's own API | Use the job's `GITEA_TOKEN` (a PAT-equivalent, short-lived for the job) as the `WebIdentityToken` to STS. | **Rejected**: STS rejects non-OIDC tokens; `GITEA_TOKEN` is not a JWT, has no `iss`/`sub`/`aud` claims, and AWS IAM has no Gitea OIDC provider to trust. (This is exactly the gap #33681 describes for GCP.) | | (d) Short-lived AWS creds via a scheduled credential mint | A platform job (cron) mints `aws sts get-session-token` (or a role-session) and writes the temp creds as a Gitea Actions secret with a TTL ≤ 1h. The spike workflow reads the secret. | Workable, but reintroduces a long-lived key *upstream* (the mint job needs one) and a secret in Gitea — a narrower version of the very thing §12.5 forbids. Acceptable as a documented spike-only waiver if (a) and (b) are both rejected for the spike scope. | | (e) LocalStack as an AWS stand-in | Replace real AWS with LocalStack for the spike; no IAM trust needed at all (LocalStack mocks STS). | Workable for the *mechanics* of `terraform plan` but **invalidates REQ-23** ("real AWS via OIDC") and the spike's whole purpose of proving real-AWS feasibility. Reject for the spike; keep as a unit-test substrate only. | | (f) Documented spike-only waiver: rotate a long-lived key per-run | One IAM access key, stored as a Gitea Actions secret, used by the workflow, rotated (deactivated + new key) after each spike run by the same workflow. | The cleanest *available* option that still touches real AWS. Still violates the *letter* of §12.5 ("long-lived credentials are forbidden") but satisfies the *intent* for a time-boxed spike: the key's useful lifetime equals one workflow run (minutes), not "long-lived." Requires an explicit, logged waiver. | | (g) GitHub-hosted mirror pipeline | Run the OIDC-requiring step on GitHub Actions (which supports `id-token: write`) against the same repo mirrored from Gitea. | Rejected: introduces a second forge, violates the "Forge: Gitea" constraint, and defeats the spike's purpose of proving the platform works on Gitea. | ### Recommendation (concrete) **Adopt option (f) as a documented spike-only waiver (propose as D-039), and commit to option (b) for v1.2.** The spike achieves real `terraform plan` against real AWS without a *persistently* long-lived key: the key is minted by Phase 08, used by the Phase 09/10 workflow, and rotated immediately after each run. D-034 (the one-shot bootstrap waiver) already permits a single bootstrapping `aws iam` call; D-039 extends that with a per-run rotation discipline so the spike never leaves a usable key behind. Exact Gitea Actions workflow snippet for the spike (Phase 09/10), assuming option (f): ```yaml # .gitea/workflows/spike-plan.yml (research sketch — not implementation) name: acdl-spike-plan on: workflow_dispatch: inputs: contract-ref: description: "Ref carrying the contract" required: false default: main type: string jobs: plan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: "Install terraform + checkov" run: | # HashiCorp apt repo (see TARGET 2) wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt-get update && sudo apt-get install -y terraform jq python3-pip pip3 install --break-system-packages checkov - name: "Assume role via short-lived key (spike waiver D-039)" env: AWS_ACCESS_KEY_ID: ${{ secrets.SPIKE_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SPIKE_AWS_SECRET_ACCESS_KEY }} AWS_SESSION_TOKEN: ${{ secrets.SPIKE_AWS_SESSION_TOKEN }} # set if the secret is a session token AWS_REGION: us-east-1 run: | # Mint a *fresh* short-lived session for this run (≤ 1h TTL). # The SPIKE_AWS_* secret is the one-key bootstrap (D-034); the # session creds below are what terraform sees. STS_JSON=$(aws sts get-session-token --duration-seconds 3600) aws sts assume-role --role-arn arn:aws:iam::${{ secrets.SPIKE_AWS_ACCOUNT_ID }}:role/acdl-act-runner-role \ --role-session-name acdl-spike-${{ gitea.run_id }} \ --duration-seconds 3600 > /tmp/role.json # Export the assumed-role creds into the subsequent step env. { echo "AWS_ACCESS_KEY_ID=$(jq -r .Credentials.AccessKeyId /tmp/role.json)" echo "AWS_SECRET_ACCESS_KEY=$(jq -r .Credentials.SecretAccessKey /tmp/role.json)" echo "AWS_SESSION_TOKEN=$(jq -r .Credentials.SessionToken /tmp/role.json)" } >> "$GITHUB_ENV" aws sts get-caller-identity - name: "terraform init + plan" run: | cd terraform terraform init -input=false terraform plan -input=false -out=tfplan.binary terraform show -json tfplan.binary > tfplan.json - name: "Checkov → PolicyCheckResult" run: | checkov -f terraform/tfplan.json --framework terraform_plan \ --check CKV_AWS_18,CKV_AWS_19,CKV_AWS_20,CKV_AWS_21,CKV_AWS_41,CKV_AWS_45,CKV_AWS_46,CKV_AWS_1,CKV_AWS_24,CKV_AWS_25,CKV_AWS_33,CKV_AWS_7 \ --output json --output-file-path checkov.json python3 adapters/terraform/policy/checkov_adapter.py checkov.json > policy_results.json # ... contract→IR → plan → confidence → outbox (Phase 10) - name: "Rotate the bootstrap key (D-039 discipline)" if: always() env: AWS_ACCESS_KEY_ID: ${{ secrets.SPIKE_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SPIKE_AWS_SECRET_ACCESS_KEY }} AWS_REGION: us-east-1 run: | # Deactivate + delete the key this run just used, then mint a new # one and write it back to the Gitea secret via the API. # (Sketch — Phase 08 will implement scripts/rotate_spike_key.sh.) bash scripts/rotate_spike_key.sh ``` **Note on the env-var name:** Gitea Actions exposes the runner context as `gitea.*` (e.g. `gitea.run_id`) and the GitHub-compatible `github.*` aliases work too (confirmed in the Gitea FAQ). `$GITHUB_ENV` is the canonical step- env file in both. The snippet uses both forms deliberately to stay GitHub-compatible. ### What this means for §12.5 and REQ-23 - **§12.5 ("long-lived credentials are forbidden")** is a *target architecture* commitment, not a spike constraint. The architecture's own Gitea API surface table already hedged: `id-token: write` / OIDC was "To be confirmed in RESEARCH." This research confirms it is *not* available. The locked target stands for v1.2+; the spike uses the documented waiver. - **REQ-23** ("AWS OIDC trust is configured … the temporary long-lived key … is rotated immediately after (waiver D-034)") is *re-interpreted* by this research: the "OIDC trust" cannot be configured in this environment yet. REQ-23's *intent* (real AWS, no persistent long-lived key) is met by D-039's per-run rotation. REQ-23 should be amended in Phase 07 to read "AWS trust is configured for the spike via a per-run-rotated key (waiver D-039); OIDC federation is the v1.2 target, blocked on go-gitea/gitea#36988." --- ## TARGET 2 — Terraform + Checkov availability on the act_runner image ### Findings - The default Gitea runner image is `gitea/runner-images:ubuntu-latest`, which is built from `catthehocker/ubuntu:act-24.04` (https://gitea.com/gitea/runner-images). This mirrors the GitHub Actions `ubuntu-latest` image (catthehocker/ubuntu). - `catthehocker/ubuntu:act-24.04` is a community-maintained clone of the GitHub-hosted Ubuntu runner. **Neither `terraform` nor `checkov` is pre-installed** on the GitHub-hosted runners, and therefore neither is on the Gitea runner image. Confirmed by the runner-images README ("Images are built from `catthehocker/ubuntu:*` or `node:*`. Additional packages will be installed if they are needed by `runner`") — i.e. only what `gitea-runner` itself needs is added. - `terraform` install: use the official HashiCorp apt repository (the recommended path; not `tfenv` — `tfenv` adds a shell-init step that is unnecessary for a single-version spike). - `checkov` install: `pip3 install checkov`. Checkov requires Python ≥ 3.9 and ≤ 3.12 (per the GitHub README "Requirements"; Python 3.13 is *not* in the tested matrix — the README says "3.9 - 3.13" but the badge matrix historically stops at 3.12; the `act-24.04` image ships Python 3.12, so this is fine). On Debian 12+ / Ubuntu 24.04, pip refuses to install into the system environment — use `--break-system-packages` (acceptable inside an ephemeral job container) or a venv. The spike uses `--break-system-packages` for simplicity (the container is thrown away after the job). ### Confidence 0.85 — the runner-image composition is documented; the *exact* packages on `catthehocker/ubuntu:act-24.04` are not enumerated in the Gitea docs (the image is third-party), but the GitHub-hosted-runner analogue is well known to exclude terraform. The 0.15 residual is for the possibility that a future `gitea/runner-images:ubuntu-latest-full` adds terraform. ### Assumptions logged - **A-2.1** (0.90): the act_runner at git.cloudinit.dev uses `runs-on: ubuntu-latest` → `docker://docker.gitea.com/runner-images:ubuntu-latest` (the documented default). Confirmable from the runner admin page in Phase 08. - **A-2.2** (0.90): no `runs-on:` label on the instance maps to an image that pre-installs terraform/checkov. ### Recommendation - `runs-on: ubuntu-latest` (the default). - Install step as sketched in the TARGET 1 workflow snippet. Pin `terraform` to a known version (e.g. `terraform=1.9.*` — the spike does not need the latest; pinning avoids a surprise major bump mid-spike). - Pin `checkov` to a known version (e.g. `checkov>=3.2,<4`) to keep the rule-ID set stable across spike runs. - Checkov rule IDs for the L2 checks (see TARGET 4 for the full mapping): the 4 L2 checks + tag/naming map to *existing* Checkov rules (CKV_AWS_18, CKV_AWS_19, CKV_AWS_20, CKV_AWS_21, CKV_AWS_41, CKV_AWS_45, CKV_AWS_46, CKV_AWS_1, CKV_AWS_24, CKV_AWS_25, CKV_AWS_33, CKV_AWS_7). No custom Checkov checks are needed for the spike. Tag/naming convention is *not* a built-in Checkov check; the spike implements it as a tiny custom YAML policy (see TARGET 4) or defers tag/naming to the confidence signal's NFR input for the spike and adds the custom Checkov rule in v1.2. --- ## TARGET 3 — Target Stack IR prior art + v1 shape ### Findings (prior art survey) - **Pulumi resource model** (https://www.pulumi.com/docs/concepts/): a program declares `Resource` objects with `inputs` (typed props), `outputs` (resolved after create), and explicit `dependsOn` / parent-child links. Pulumi's resource is *not* a tree — it's a DAG — and the parent relationship is for *composition* (e.g. a ComponentResource wrapping child resources), not for hard parent-single-child. This is more general than the ACDL IR needs in v1 (single-parent, max-depth-5 tree). - **Terraform CDK (cdktf)**: compiles TypeScript/Python/etc. to Terraform HCL. The intermediate is a `TerraformAsset`/`TerraformElement` graph that is nearly 1:1 with HCL. Confirms the "nearly isomorphic to Terraform in v1" claim in architecture.md §12.1 — CDK's IR *is* HCL-shaped. - **Crossplane Compositions**: a `CompositeResourceDefinition` (XRD) declares a schema; a `Composition` templates patches from the composite to managed resources. Patching is by *path*, not by typed contract. Crossplane's model is *runtime* (the controller reconciles), whereas ACDL's IR is *build-time* (the adapter compiles to a plan). Crossplane validates that this separation (IR ≠ runtime) is a workable design. - **ACDL v1.0 demo** (`modules/l1/l1-s3/manifest.yaml`, `modules/l2/l2-invoice-service/manifest.yaml`): the demo's shape is: ```yaml # L1 manifest (modules/l1/l1-s3/manifest.yaml) name: l1-s3 kind: l1 description: Object store primitive inputs: bucket_name: { description: ..., type: string } region: { description: ..., type: string } retention_days: { description: ..., type: string } ``` ```yaml # L2 manifest (modules/l2/l2-invoice-service/manifest.yaml) name: l2-invoice-service kind: l2 description: ... l1s: - name: l1-eks-fargate inputs: { cluster_name: invoice-cluster, region: us-east-1, cpu_arch: arm64 } - name: l1-s3 inputs: { bucket_name: acdl-invoice-archive, region: us-east-1, retention_days: "365" } ``` The demo's L2 `l1s:` list is a *flat* composition (no nesting, no relationships). The v1 IR upgrades this to a *tree* with explicit relationships (single parent per child, shared keyword for multi-rel) and typed outputs. ### Confidence 0.80 — the prior-art survey is grounded; the v1 IR shape is a *recommendation* (not externally validated), confidence will rise once Phase 09 implements the adapter and the round-trip to Terraform is verified. ### Assumptions logged - **A-3.1** (0.85): the IR's "nearly isomorphic to Terraform in v1" claim (architecture.md §12.1) is the right v1 boundary — build a thin IR, defer substrate-specific expressiveness to v2. - **A-3.2** (0.80): single-parent-per-child is sufficient for v1 (no L1 needs two parents in the spike). The "shared keyword for multi-relationship" (architecture.md §12.1) is a v2 concern; the v1 schema reserves the field but the spike does not exercise it. ### Recommendation — v1 IR JSON shape (sketch for `schemas/ir.schema.json`) The IR is a *tree* of `resources` with typed `inputs`, `outputs`, `nfrs`, and a single `parent` reference. Composition metadata (max depth, registry version) lives at the root. Policy hooks are *not* in the IR — they attach at the L2-composition / pipeline stage, not in the resource definition (this keeps the IR purely descriptive, matching the architecture's "policy hooks are the points in the composition where policy checks attach" — the hooks are on the *composition*, not the resource). ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://acdl.cloudinit.dev/schemas/ir.schema.json", "title": "ACDL Target Stack IR", "type": "object", "required": ["version", "stack", "resources"], "properties": { "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, "stack": { "type": "object", "required": ["name", "kind", "depth"], "properties": { "name": { "type": "string", "pattern": "^l[12]-[a-z][a-z0-9-]*$" }, "kind": { "enum": ["l1", "l2"] }, "depth": { "type": "integer", "minimum": 1, "maximum": 5 } } }, "resources": { "type": "array", "items": { "$ref": "#/$defs/resource" }, "minItems": 1 }, "relationships": { "type": "array", "items": { "$ref": "#/$defs/relationship" } } }, "$defs": { "resource": { "type": "object", "required": ["id", "type", "module", "inputs"], "properties": { "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "type": { "type": "string", "description": "IR resource type, e.g. aws:s3:bucket" }, "module": { "type": "string", "pattern": "^l1-[a-z][a-z0-9-]*@\\d+\\.\\d+\\.\\d+$", "description": "L1 registry reference (name@semver, per W3.D)" }, "parent": { "type": "string", "description": "id of the single parent resource; absent for the root" }, "inputs": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean", "object", "array", "null"] }, "description": "Typed input contract; validated against the L1 module's declared inputs" }, "outputs": { "type": "object", "additionalProperties": { "$ref": "#/$defs/outputSpec" }, "description": "Typed output contract; the adapter translates these to Terraform outputs" }, "nfrs": { "type": "object", "description": "Non-functional requirements (latency, throughput, rpo, rto, etc.); opaque to the adapter, consumed by the confidence signal's NFR input", "additionalProperties": true } } }, "outputSpec": { "type": "object", "required": ["type"], "properties": { "type": { "type": "string", "description": "IR type, e.g. string, arn, ref:." }, "description": { "type": "string" } } }, "relationship": { "type": "object", "required": ["from", "to", "kind"], "properties": { "from": { "type": "string", "description": "resource id" }, "to": { "type": "string", "description": "resource id" }, "kind": { "enum": ["parent", "depends_on", "uses_output"], "description": "parent = single-parent composition; depends_on = ordering only; uses_output = output reference. v1 uses parent + uses_output only." }, "shared_keyword": { "type": "string", "description": "For multi-relationship (v2); reserved, unused in v1." } } } } } ``` **Why this shape round-trips to Terraform cleanly (v1):** - `resource.module` → `module "" { source = "..."; version = "..."; }`. - `resource.inputs` → Terraform `variable {}` block in the L1 module + argument values in the L2 root module's `module` block. - `resource.outputs` → Terraform `output {}` block in the L1 module + `output {}` (passthrough) in the L2 root module. - `relationship.kind = uses_output` with `to = "X.out"` → Terraform interpolation `module.X.`. - `relationship.kind = parent` → the child resource is *inside* the parent L1's module block (no Terraform construct; it's a composition hint the adapter uses to order module blocks). For the spike (`l2-static-assets` → `l1-s3` only, depth 1) there is exactly one resource and zero relationships — the IR still validates, and the adapter produces a single `module "s3" { ... }` block. --- ## TARGET 4 — PolicyCheckResult + Checkov adapter ### Findings (Checkov JSON output shape) Checkov's JSON output (per the GitHub README and CLI reference) is a JSON object keyed by framework, each containing `results` with `passed_checks`, `failed_checks`, and `skipped_checks` arrays. Each check record has at minimum: `check_id`, `check_name`, `check_result` (`{ "result": "PASSED" | "FAILED" }`, plus `evaluations`), `file_path`, `file_abs_path`, `repo_file_path`, `resource`, `resource_address`, `code_block`, `severity` (when available; requires Prisma Cloud API for full severity metadata, but Checkov emits `severity` for many built-in checks), `guideline`, `bc_category_id`. For `terraform_plan` framework, the `file_path` is the plan JSON and `resource` is the Terraform address (e.g. `aws_s3_bucket.customer`). The adapter must: 1. Run Checkov with `--framework terraform_plan --output json --output-file-path checkov.json --soft-fail` (so Checkov never exits non-zero; the confidence signal decides the gate, not Checkov's exit code — matching the v1.0 demo's `confidence_signal.py` discipline of always-exit-0). 2. Read the JSON, iterate the `failed_checks` + `passed_checks` + (if present) `skipped_checks`, and emit one `PolicyCheckResult` per check. ### Checkov rule → ACDL L2-check + severity mapping | ACDL L2 check | Checkov rule ID(s) | Checkov default severity | PolicyCheckResult severity | |---------------|---------------------|---------------------------|----------------------------| | secrets-in-plaintext | `CKV_AWS_41` (provider creds), `CKV_AWS_45` (lambda env), `CKV_AWS_46` (EC2 userdata) | HIGH | high | | public ingress | `CKV_AWS_20` (S3 public read ACL), `CKV_AWS_57` (S3 public write ACL), `CKV_AWS_24` (SG 0.0.0.0/0 → 22), `CKV_AWS_25` (SG 0.0.0.0/0 → 3389) | HIGH (S3 ACL), MEDIUM (SG) | high (S3 ACL), medium (SG) — the S3-public-ACL is the spike's exercised case | | IAM wildcard | `CKV_AWS_1` (admin `*:*` policy document), `CKV_AWS_40` (policy attached to user) | HIGH (CKV_AWS_1), MEDIUM (CKV_AWS_40) | high (CKV_AWS_1), medium (CKV_AWS_40) | | KMS key reference | `CKV_AWS_7` (KMS rotation), `CKV_AWS_33` (KMS wildcard principal) | MEDIUM | medium | | tag compliance | (no built-in Checkov rule for *tag presence*; `CKV_AWS_51` is ECR immutable tags, not general tag compliance) | n/a | low (spike-only: emit a `SKIPPED` PolicyCheckResult with ruleId `ACDL_TAG_NAMING` and a "deferred to v1.2" message) | | naming convention | (no built-in) | n/a | low (same — `ACDL_TAG_NAMING` skipped in spike) | **Severity mapping (Checkov → PolicyCheckResult):** Checkov severities are `CRITICAL, HIGH, MEDIUM, LOW, INFO` (some require the Prisma Cloud API). Map 1:1 to the PolicyCheckResult severity enum (lowercase). Where Checkov does not emit a severity (no API key in the spike — we run without `--bc-api-key`), fall back to a *default severity table* baked into the adapter (above) — this is the spike's "no Prisma Cloud" path and matches the v1.0 demo's `policy_checker.py` deterministic discipline. **Critical-override semantics** (architecture.md §8): if *any* PolicyCheckResult has `severity: critical` AND `result: fail`, the confidence signal hard-overrides the score to a mandatory block regardless of other inputs. None of the L2-check rules above are *critical* by default; the spike does not exercise the critical path (the v1.0 demo's Act 4 used a `high`-equivalent public-ingress). The architecture reserves `critical` for future rules (e.g. a custom Checkov rule that flags a resource that would expose customer data). ### Confidence 0.85 — the Checkov JSON shape is documented and stable; the rule IDs are verified against the Checkov policy index. The 0.15 residual is for Checkov minor-version field-name drift (e.g. `resource_address` vs `resource`); the adapter must defensively read both. ### Assumptions logged - **A-4.1** (0.85): the spike runs Checkov without a Prisma Cloud API key, so severities come from the adapter's baked-in default table, not Checkov's `severity` field. If Checkov *does* emit a `severity`, the adapter prefers it. - **A-4.2** (0.80): tag/naming is deferred for the spike (a `SKIPPED` PolicyCheckResult is sufficient to satisfy the "all six inputs present" gate per §8 — the policy input is present, it just says "skipped"). A custom Checkov YAML rule for tag presence lands in v1.2. ### Recommendation — Checkov adapter sketch (Python) ```python # adapters/terraform/policy/checkov_adapter.py (RESEARCH SKETCH — not implementation) """Translate Checkov JSON output to ACDL PolicyCheckResult records. Reads Checkov's JSON output (one framework key, e.g. "terraform_plan"), emits a list of PolicyCheckResult dicts conforming to schemas/policy_check_result.schema.json. """ import json import sys import datetime # Checkov rule ID → (ACDL L2 check name, default severity when Checkov omits one) RULE_MAP = { "CKV_AWS_41": ("secrets-in-plaintext", "high"), "CKV_AWS_45": ("secrets-in-plaintext", "high"), "CKV_AWS_46": ("secrets-in-plaintext", "high"), "CKV_AWS_20": ("public-ingress", "high"), "CKV_AWS_57": ("public-ingress", "high"), "CKV_AWS_24": ("public-ingress", "medium"), "CKV_AWS_25": ("public-ingress", "medium"), "CKV_AWS_1": ("iam-wildcard", "high"), "CKV_AWS_40": ("iam-wildcard", "medium"), "CKV_AWS_7": ("kms-key-reference", "medium"), "CKV_AWS_33": ("kms-key-reference", "medium"), } def _iso8601_now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _to_pcr(checkov_record, contract_id, result_str): rule_id = checkov_record["check_id"] acdl_check, default_sev = RULE_MAP.get(rule_id, (rule_id, "info")) severity = checkov_record.get("severity", default_sev).lower() return { "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "checkov", "ruleId": rule_id, "severity": severity, "result": {"PASSED": "pass", "FAILED": "fail", "SKIPPED": "skipped"}.get(result_str, "error"), "message": checkov_record.get("check_name", ""), "evidence": { "file_path": checkov_record.get("file_path"), "resource": checkov_record.get("resource"), "resource_address": checkov_record.get("resource_address"), "code_block": checkov_record.get("code_block"), }, "resourceRef": checkov_record.get("resource_address") or checkov_record.get("resource", ""), } def adapt(checkov_json_path, contract_id): with open(checkov_json_path) as fh: data = json.load(fh) # Checkov JSON: { "": { "results": { "passed_checks":[], "failed_checks":[], "skipped_checks":[] } } } out = [] for _framework, body in data.items(): results = body.get("results", body) # tolerate both shapes for rec in results.get("passed_checks", []): out.append(_to_pcr(rec, contract_id, "PASSED")) for rec in results.get("failed_checks", []): out.append(_to_pcr(rec, contract_id, "FAILED")) for rec in results.get("skipped_checks", []): out.append(_to_pcr(rec, contract_id, "SKIPPED")) return out if __name__ == "__main__": checkov_path, contract_id = sys.argv[1], sys.argv[2] print(json.dumps(adapt(checkov_path, contract_id), indent=2)) ``` **Note on the spike's tag/naming handling:** the spike emits a single `PolicyCheckResult` with `ruleId: "ACDL_TAG_NAMING"`, `result: "skipped"`, `severity: "info"`, `message: "tag/naming check deferred to v1.2"` from the adapter *after* the Checkov pass — so the confidence signal sees the "all six inputs present" condition (the policy input is a non-empty list) without the spike needing a custom Checkov rule. --- ## TARGET 5 — DynamoDB outbox pattern (RPO=0) ### Findings - **RPO=0 mechanics:** the contract-submission handler writes to the DynamoDB outbox *synchronously* in the same transaction (or same step) that acks the submission. The ack does not return until the outbox write is durable (DynamoDB strong-consistent write). This is the standard "transactional outbox" pattern (https://microservices.io/patterns/data/transactional-outbox.html). - **DynamoDB mode:** `PAY_PER_REQUEST` (on-demand) for the spike — no capacity planning, no minimum cost, scales to zero. Provisioned is for steady-state high-throughput (v1.2+ when the pipeline has real load). - **Worker design for the spike:** there is *no* separate async worker in the spike. The pipeline step that computes the confidence signal writes the evidence event to the outbox *synchronously* in the same step. The "async worker + DLQ" is a v1.2 concern; the spike's RTO is "the step re-runs" (Gitea Actions re-runs the workflow on failure). This is acceptable because the spike is a single contract, single run, dev-only. - **Outbox table schema (spike minimum):** - PK: `contractId` (UUID) - SK: `eventType#eventTs` (e.g. `POLICY_CHECKED#2026-07-21T12:00:00Z`) — sorts events per contract chronologically. - Attributes: `payload` (the JWS-signed event body, see TARGET 7), `prev_event_hash` (the chain link), `approver_qa` (GitHub username of the QA approver; empty in the dev-only spike), `approver_prod` (SRE; empty in spike), `environment`, `stack`, `score`, `band`. - TTL: `expire_at` (1 year per §8 "1-year storage"; set to now + 365d). - **Approver identities:** stored on the *first* event for a contract (the submission event) as `approver_qa` / `approver_prod`, updated on the promotion events. The separation-of-duties check (§10.3) reads `approver_qa` at the prod-promotion step and compares to the new `approver_prod`. In the dev-only spike, both are empty — the check is a no-op stub that returns "distinct" (the spike does not exercise identity distinctness; it only proves the storage path works). ### Confidence 0.85 — the transactional-outbox pattern is well-established; the spike minimum (synchronous write, no async worker) is a deliberate scope cut, not a design risk. ### Assumptions logged - **A-5.1** (0.90): DynamoDB on-demand is the right spike mode (zero cost when idle, no capacity planning). - **A-5.2** (0.85): the spike's "RTO = re-run the workflow" is acceptable because the spike is a single dev-only submission; the v1.2 outbox worker + DLQ is the production RTO design. ### Recommendation - DynamoDB table `acdl-outbox`, on-demand, single-region (us-east-1). - PK `contractId`, SK `eventType#eventTs`. - The pipeline step writes the evidence event synchronously via boto3 `put_item` (strong-consistent by default for DynamoDB). - The v1.0 demo's `evidence_writer.py` hash chain (see TARGET 7) is lifted for the spike's `prev_event_hash` field; the JWS signature is deferred to v1.2 (TARGET 7). - No separate worker / DLQ / EventBridge / Lambda for the spike. Phase 07's `platform/audit_ledger_design.md` documents the v1.2 async-worker + DLQ design; the spike implements only the synchronous write path. --- ## TARGET 6 — Six-input confidence signal ### Findings The architecture (§8) locks "six canonical inputs" but does not enumerate them. Cross-referencing the vision's "Safety is Computed" tenet ("aggregating policy conformance, validation evidence, and historical behavior") and the HITL matrix in §10.4 (which enumerates: functional correctness, performance baseline, security posture, contract NFRs, operational readiness, incident response, capacity/cost, resilience), the 6 inputs must cover *what the platform can compute autonomously in dev* (dev has no HITL matrix — §5's "dev = Full autonomy, all six inputs present"). The HITL matrix's 8 concerns are *qa/prod/dr* concerns; the confidence signal's 6 inputs are the *platform-computable* subset that exists in *every* environment (including dev). ### Recommended 6 inputs (with weights summing to 1.0) | # | Input | Weight | What it is | Dev source | |---|-------|--------|------------|-----------| | 1 | policy check results | 0.30 | List of PolicyCheckResult records (§12.6); severity-weighted penalty | Checkov adapter (TARGET 4) | | 2 | validation evidence | 0.25 | Schema-validity + IR-resolution-success + terraform-plan-success (the pipeline's own build/test gates) | pipeline steps (schema validate → IR resolve → `terraform validate` → `terraform plan`) | | 3 | freshness | 0.10 | Age of the contract's declared validation evidence (e2eSuite, loadTest) relative to submission; in dev, this is the age of the L1/L2 module versions vs. the registry | L1 registry publication timestamps | | 4 | source / attestation | 0.15 | Identity of the submitter + the contract's source provenance (git ref, commit SHA, signed-by). In dev (autonomous), this is "any valid submitter" — the gate is *presence*, not *identity*. | Gitea `gitea.actor` + commit SHA | | 5 | historical behavior | 0.10 | Platform's observed history for this contract / stack / submitter: prior rollback count, prior policy-fail count. In the spike (first submission), this is a neutral 0.5 (no history). | DynamoDB outbox (prior events for this `contractId` / `stack`) | | 6 | NFR conformance | 0.10 | The contract's declared NFRs (latency, throughput, error rate) vs. the platform's measured baseline for this stack. In the spike, `l2-static-assets` declares no NFRs, so this input is "present + neutral 0.5" (the gate is *presence*, not *conformance*). | contract `nfrs` block (optional) + platform baseline (none in spike) | **Weights sum to 1.0.** The base score (before severity penalties) is the weighted sum of each input's per-input score (each in [0,1]). The severity→penalty mapping (locked, §8) is then applied as a *deduction* from the weighted sum: any critical finding hard-overrides to 0 (block); each high finding deducts 0.2, medium 0.05, low 0.01, info 0.0. The final score is clamped to [0,1]. **Per-env thresholds** (locked, §8): dev ≥ 0.50, qa ≥ 0.75, prod ≥ 0.90, dr ≥ 0.95. `band` ∈ {`pass`, `warn`, `block`} where `warn` is the band between "pass" and "block" (e.g. for dev: ≥ 0.50 = pass, 0.40–0.50 = warn, < 0.40 = block — the warn band is a v1.2 signal for the HITL reviewer; in dev (autonomous) warn is treated as block since there is no reviewer). ### Confidence 0.75 — the 6 inputs are a *recommendation*; the architecture does not enumerate them, so this research is choosing. The weights are a starting point (BA.B "thresholds frozen for v1, tuning begins v1.2" applies to the *thresholds*; the *weights* should be frozen alongside for v1 and tuned together in v1.2). ### Assumptions logged - **A-6.1** (0.80): "all six inputs present" (§5, dev gate) means each input produces a non-null per-input score; a missing input halts with an explicit reason (§8 "halt with explicit reason on missing input"). - **A-6.2** (0.75): the spike's inputs 3/5/6 are "present + neutral 0.5" because the spike is the first submission with no history and no declared NFRs. This is documented as the "cold start" baseline. - **A-6.3** (0.85): `warn` band is needed in v1.2 for qa/prod/dr HITL review; in dev (autonomous, no reviewer) `warn` is treated as `block`. ### Recommendation — Python module sketch ```python # platform/confidence_signal.py (RESEARCH SKETCH — not implementation) """Six-input weighted-sum confidence signal. Inputs (weights sum to 1.0): 1. policy_results (0.30) — list[PolicyCheckResult] 2. validation (0.25) — {schema: bool, ir_resolved: bool, tf_validated: bool, tf_planned: bool} 3. freshness (0.10) — {age_days: float, max_age_days: float} 4. source (0.15) — {submitter: str, commit_sha: str, signed: bool} 5. history (0.10) — {prior_rollbacks: int, prior_policy_fails: int} 6. nfrs (0.10) — {declared: list[str], conformance: float|None} Severity → penalty (locked, §8): critical → hard override (score = 0, block) high → -0.20 medium → -0.05 low → -0.01 info → 0.00 """ from dataclasses import dataclass, field from typing import Literal WEIGHTS = {"policy":0.30,"validation":0.25,"freshness":0.10,"source":0.15,"history":0.10,"nfrs":0.10} PENALTY = {"critical":None,"high":0.20,"medium":0.05,"low":0.01,"info":0.0} # None = hard override THRESHOLDS = {"dev":0.50,"qa":0.75,"prod":0.90,"dr":0.95} @dataclass class Signal: score: float band: Literal["pass","warn","block"] perInput: dict reasonCodes: list[str] = field(default_factory=list) def _per_input_score(name, raw): """Normalize a raw input to [0,1]. Spike cold-start: unknown inputs → 0.5.""" if raw is None: return 0.5, f"INPUT_MISSING:{name}" # halt later if *required* # ... per-input scoring rules (see full spec in platform/confidence_signal.py) return 0.5, "" def compute(contract_id, environment, inputs): if set(inputs.keys()) != set(WEIGHTS.keys()): missing = set(WEIGHTS) - set(inputs.keys()) return Signal(0.0, "block", {}, [f"INPUT_MISSING:{m}" for m in missing]) per_input = {} reasons = [] base = 0.0 for name, raw in inputs.items(): score, reason = _per_input_score(name, raw) if reason: reasons.append(reason) per_input[name] = score base += WEIGHTS[name] * score # Severity penalties (from the policy_results input) penalty = 0.0 for pcr in inputs.get("policy_results", []): if pcr["result"] != "fail": continue sev = pcr["severity"] if PENALTY[sev] is None: # critical → hard override return Signal(0.0, "block", per_input, reasons + [f"CRITICAL_OVERRIDE:{pcr['ruleId']}"]) penalty += PENALTY[sev] score = max(0.0, min(1.0, base - penalty)) threshold = THRESHOLDS[environment] band = "pass" if score >= threshold else ("block" if score < threshold - 0.10 else "warn") # dev (autonomous) treats warn as block (no reviewer) if environment == "dev" and band == "warn": band = "block" return Signal(score, band, per_input, reasons) ``` --- ## TARGET 7 — Tiered audit ledger (S3 Object Lock + JWS + chain) ### Findings - **S3 Object Lock modes:** - **Compliance mode:** once written, *no one* (including the root account) can delete or overwrite the object until the retention expires. This is the WORM guarantee regulators want. - **Governance mode:** the root account *can* delete (with `s3:BypassGovernanceRetention` permission); privileged users can override. Useful for internal policy, not for regulatory evidence. - The architecture locks **compliance mode** (§9), 7-year retention. For the spike, Object Lock is *deferred* (see recommendation below) — the spike writes to the DynamoDB outbox + the existing `acdl-evidence` audit repo (the v1.0 demo's path), and S3 Object Lock is the v1.2 cold-tier build-out. - **JWS (RFC 7515) detached signature:** the event payload is canonical-JSON-serialized, hashed (SHA-256), and signed with a private key; the signature is stored *separately* (detached) alongside the payload. Verification re-canonicalizes the payload and checks the signature. The signing key question: per-contract (one key per contractId) vs platform (one key for the whole platform). - **Per-contract:** stronger isolation (a key compromise affects one contract), but requires key management per contract (expensive at scale, and the contract's first event has no key yet — a chicken-and- egg). - **Platform:** one signing key (or a small rotation set) for the whole platform. Simpler, matches the "platform is the only writer to the outbox" (§10.3 step 4) design. A compromise of the platform key compromises *all* evidence, but so would a compromise of the platform itself (the platform is the writer). **Recommend platform-level key, stored in AWS KMS, rotated quarterly.** - **`prev_event_hash` chain:** the v1.0 demo's `evidence_writer.py` (read at `/root/acdl/scripts/evidence_writer.py`) already implements this *exactly*: - Build event dict with `hash = ""` (empty string). - `canonical = json.dumps(event, sort_keys=True, separators=(",", ":"))`. - `hash = sha256(canonical.encode("utf-8")).hexdigest()`. - Set `event["hash"] = hash`. - The next event's `prev_hash` = the previous event's `hash`. - Auto-genesis: if the log is empty, insert a genesis event (`seq=0, prev_hash="GENESIS"`). - **Lift this verbatim** for the spike's outbox events. The JWS signature wraps the same `hash` (or the canonical JSON) — the chain and the signature are orthogonal: the chain gives ordering/tamper- evidence *within* the log; JWS gives authenticity *per event*. - **Daily checkpoints (§9):** a daily job reads the last event hash and writes a "checkpoint" event to the ledger (and optionally to a public notarization service). For the spike, no daily checkpoint (the spike runs in minutes, not days). - **GitHub audit repo (`acdl-evidence`)** is the *hot query index*, not part of the chain (§9). The v1.0 demo already commits `audit.json` to `acdl-evidence` via `finalize_evidence.py`. The spike continues this: the outbox is the source of truth; `acdl-evidence` is a queryable mirror (the timeline UI at `evidence-ui/index.html` reads it). ### Spike minimum (recommendation) - **Defer S3 Object Lock + JWS to v1.2.** The spike's evidence event is: the v1.0 demo's hash-chained shape, written to the DynamoDB outbox (TARGET 5) and mirrored to `acdl-evidence` (unchanged from v1.0). - The spike's `audit_ledger_design.md` (Phase 07) documents the v1.2 S3-Object-Lock-compliance + JWS + KMS-key + daily-checkpoint design *in full*, but the spike implements only the hash-chain + outbox write. - This is acceptable because the spike's goal (REQ-28) is to prove the *IR commitments hold*, not to prove the audit ledger's regulatory posture. The audit ledger's regulatory posture is a design artifact (Phase 07) + a v1.2 build. ### Confidence 0.85 — the v1.0 demo's hash chain is read and confirmed; the deferral of Object Lock + JWS is a scope decision, not a design risk. ### Assumptions logged - **A-7.1** (0.85): the spike can defer S3 Object Lock + JWS to v1.2 without invalidating REQ-20 ("tiered audit ledger design authored") — REQ-20 is *design*, not *implementation*, and Phase 07 authors the design. - **A-7.2** (0.80): platform-level KMS signing key is the right v1.2 choice (vs per-contract); the spike does not exercise this. ### Recommendation — spike evidence event shape (lifted from v1.0 + outbox) ```json { "seq": 1, "ts": "2026-07-21T12:00:00Z", "stage": "dev", "event": "contract applied: l2-static-assets (confidence 0.82, band pass)", "prev_hash": "", "hash": "", "contractId": "uuid", "environment": "dev", "stack": "l2-static-assets", "score": 0.82, "band": "pass" } ``` The v1.2 addition is a `jws` field (detached signature over the canonical JSON) and a move of the storage from the outbox-only path to the outbox→S3-Object-Lock path. --- ## TARGET 8 — HITL matrix + separation of duties ### Findings - **GitHub Environments + required reviewers:** GitHub-native; Gitea has *no* Environments API (confirmed in v1.0 research, ARCHITECTURE.md "Gitea API surface" table; re-confirmed in the Gitea docs "Compared to GitHub Actions" → "`jobs..environment` ... It's ignored by Gitea Actions now"). The v1.0 demo's workaround (D-013: `workflow_dispatch` approval inputs) is the only available Gitea-native gate. - **CODEOWNERS:** Gitea supports CODEOWNERS files (for PR review routing); this is the routing layer (§10.2). It does *not* enforce identity distinctness (§10.3 — that's the DynamoDB outbox check). - **The spike is dev-only** (REQ-27 contract has `environment: dev`), so HITL is *not exercised* in the spike. Phase 07 authors the design; Phase 10's `verify_phase10.sh` does not assert any HITL behavior. - **Identity-distinctness check sequence (§10.3):** 1. dev→qa promotion: read QA approver GitHub identity from the GitHub Deployment approval event → write to outbox keyed by `contractId`. 2. qa→prod: read stored QA approver from outbox + new SRE approver from the approval event. 3. If `qaApprover == prodApprover`: block, emit `SEPARATION_OF_DUTIES_VIOLATION`, route halt artifact to SRE on-call. 4. The check is in the central pipeline (the platform is the only outbox writer). - **Gitea adaptation:** there is no "GitHub Deployment approval event" in Gitea. The v1.0 demo modeled this as a `workflow_dispatch` input (`approve_qa: true` / `approve_prod: true`). The *approver identity* in Gitea is `gitea.actor` of the dispatch event. The design doc must specify: "the approver identity is `gitea.actor` of the `workflow_dispatch` run that sets `approve_qa=true` (resp. `approve_prod=true`)." - **Timeout (§10.5):** 1 business day = warn + escalate; 2 business days = auto-freeze + re-submit (linked via `supersedes`). The spike does not implement the timer (no HITL in spike); the design doc specifies it as a Gitea scheduled workflow (`on: schedule`) that scans the outbox for `PENDING_ATTESTATION` events older than 1/2 business days and emits the warning/freeze events. - **Rejection (§10.6):** returns contract to `HELD` state; new submission linked via `supersedes`. The `supersedes` field is a contract-schema field (TARGET 9) pointing at the prior contractId. ### Confidence 0.85 — the Gitea adaptation (using `gitea.actor` of the dispatch event) is the only viable path given the no-Environments-API constraint; it is documented in the v1.0 research and re-confirmed. ### Assumptions logged - **A-8.1** (0.90): the spike does not exercise HITL (dev-only); Phase 07 authors the design; v1.2 wires it. - **A-8.2** (0.85): `gitea.actor` of the `workflow_dispatch` run is the approver identity of record. This is Gitea's only available approval-identity signal. - **A-8.3** (0.80): the 1d/2d timeout is a v1.2 scheduled workflow (no spike implementation). ### Recommendation — design doc sketch (`platform/hitl_matrix_design.md`) The Phase 07 design doc should contain: 1. The full 8-concern attestation matrix (lifted from `docs/architecture.md` §10.4, formatted as a Markdown table). 2. The Gitea-specific pre-execution gate model: - qa gate: `workflow_dispatch` with `approve_qa: true`; the dispatch run's `gitea.actor` is the QA approver. - prod gate: `workflow_dispatch` with `approve_prod: true`; the dispatch run's `gitea.actor` is the SRE approver. - dr gate: `workflow_dispatch` with `approve_dr: true`; same. 3. The separation-of-duties check (`platform/separation_of_duties.py`): reads `approver_qa` from the outbox for the `contractId`, compares to the new `gitea.actor` of the prod-dispatch run; blocks on equality; emits `SEPARATION_OF_DUTIES_VIOLATION`. 4. The timeout design: a `on: schedule` workflow (runs hourly) that scans the outbox for `PENDING_ATTESTATION` events with `ts` older than 1/2 business days and emits the warn/freeze events. 5. The `supersedes` linking on rejection (a contract-schema field). ```python # platform/separation_of_duties.py (RESEARCH SKETCH — not implementation) """Check that qaApprover != prodApprover for a contract. Reads the outbox for the contractId; returns (ok, reason). Spike: always returns (True, "dev-only") because the spike is dev-only. """ def check(outbox_client, contract_id, current_prod_approver): item = outbox_client.get(contract_id) if item is None: return True, "no prior approver (first promotion)" # dev→qa has no SoD check qa_approver = item.get("approver_qa") if not qa_approver: return True, "no QA approver recorded (dev-only spike)" if qa_approver == current_prod_approver: return False, f"SEPARATION_OF_DUTIES_VIOLATION: qaApprover==prodApprover=={qa_approver}" return True, "distinct" ``` --- ## TARGET 9 — Contract schema (JSON Schema draft 2020-12) ### Findings - **Per-env mandatory/optional (W3.E, resolved in PROJECT.md):** - dev requires: `stack`, `environment` - qa adds: `validation.e2eSuite`, `validation.loadTest` - prod adds: `runbook`, `dashboard`, `oncall` - dr adds: `drDrillRef` - `inputs` always optional - `profile: agentic` fields optional everywhere - **`profile: agentic`** marker unlocks L3B fields: `naturalLanguageIntent`, `confidenceAtSubmission`, `agentTrace` (architecture.md §5). - **Central repo + generated clients:** the spike uses a *local* `schemas/contract.schema.json`; the central repo + generated client libraries are v1.2 (architecture.md §7). - **Fail-fast with reason codes:** schema validation failure produces a reason code from a published vocabulary (e.g. `SCHEMA_MISSING_REQUIRED:stack`, `SCHEMA_TYPE_MISMATCH:inputs.bucket_name`, `SCHEMA_UNKNOWN_PROFILE`). The vocabulary is a JSON list in `schemas/reason_codes.json` (Phase 07 authoring). - **`supersedes` field** (from TARGET 8): optional, points at the prior contractId on re-submission after rejection. ### Confidence 0.85 — the per-env mandatory table is locked (W3.E); the schema shape is a direct formalization. ### Assumptions logged - **A-9.1** (0.90): the spike's `contracts/spike.yaml` has only `stack`, `environment`, and `inputs` — the minimal dev contract. - **A-9.2** (0.85): `inputs` is a free-form `object` with string values (matching the v1.0 demo's `contract.yaml` shape) for v1; typed `inputs` per-L1 is a v1.2 enhancement (the IR's `resource.inputs` is typed, but the contract's `inputs` is the L2-level param map, free-form in v1). ### Recommendation — `schemas/contract.schema.json` sketch ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://acdl.cloudinit.dev/schemas/contract.schema.json", "title": "ACDL Contract", "type": "object", "required": ["stack", "environment"], "properties": { "stack": { "type": "string", "pattern": "^l2-[a-z][a-z0-9-]*$" }, "environment": { "enum": ["dev", "qa", "prod", "dr"] }, "inputs": { "type": "object", "additionalProperties": { "type": ["string","number","boolean"] }, "description": "L2-level parameter map; free-form in v1, typed in v1.2" }, "validation": { "type": "object", "properties": { "e2eSuite": { "type": "string", "description": "ref to the e2e suite" }, "loadTest": { "type": "string" } } }, "runbook": { "type": "string" }, "dashboard": { "type": "string" }, "oncall": { "type": "string" }, "drDrillRef":{ "type": "string" }, "profile": { "enum": ["developer", "agentic"], "default": "developer" }, "naturalLanguageIntent": { "type": "string" }, "confidenceAtSubmission": { "type": "number", "minimum": 0, "maximum": 1 }, "agentTrace": { "type": "string" }, "supersedes": { "type": "string", "format": "uuid", "description": "prior contractId this re-submission replaces (after rejection)" } }, "allOf": [ { "if": { "properties": { "environment": { "const": "qa" } } }, "then": { "required": ["validation"] } }, { "if": { "properties": { "environment": { "const": "prod" } } }, "then": { "required": ["runbook", "dashboard", "oncall"] } }, { "if": { "properties": { "environment": { "const": "dr" } } }, "then": { "required": ["drDrillRef"] } }, { "if": { "properties": { "profile": { "const": "agentic" } } }, "then": { "required": ["naturalLanguageIntent"] } } ] } ``` **Spike contract (`contracts/spike.yaml`) validates against this:** ```yaml stack: l2-static-assets environment: dev inputs: bucket_name: acdl-spike-bucket region: us-east-1 ``` `dev` requires only `stack` + `environment`; `inputs` optional; no `profile` (defaults to `developer`). Passes. --- ## TARGET 10 — Archive strategy (v1.0 demo → demo/) ### Findings (static analysis of the demo's path references) The demo's scripts resolve paths relative to `REPO_ROOT` (the parent of `scripts/`). Moving the demo to `demo/` means `REPO_ROOT` becomes the `demo/` directory, and the relative paths `modules/...`, `scripts/...`, `evidence-ui/...` must still resolve *inside* `demo/`. Path references found (static analysis only — no execution): 1. **`scripts/run_demo.sh`** (read at `/root/acdl/scripts/run_demo.sh`): - `SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` - `REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"` - `ev()` calls `python3 "$SCRIPT_DIR/evidence_writer.py"` — resolves to `demo/scripts/evidence_writer.py` ✓ (inside demo/). - `python3 "$SCRIPT_DIR/policy_checker.py"` ✓ - `python3 "$SCRIPT_DIR/confidence_signal.py"` ✓ - `python3 "$SCRIPT_DIR/l3b_agent_stub.py"` ✓ - `python3 "$SCRIPT_DIR/finalize_evidence.py"` ✓ (the upload path; skipped under `--no-upload`). - `( cd "$REPO_ROOT" && bash "$SCRIPT_DIR/mock_executor.sh" ... )` — `REPO_ROOT` = `demo/`, `mock_executor.sh` resolves L2 manifests relative to its cwd. - `rm -f "$REPO_ROOT/state.json"` — cleans up `demo/state.json` ✓. - `--audit "$REPO_ROOT/evidence-ui/index.html"` — resolves to `demo/evidence-ui/index.html` ✓. 2. **`scripts/mock_executor.sh`** (read at `/root/acdl/scripts/mock_executor.sh`): - `L2_MANIFEST="modules/l2/${STACK}/manifest.yaml"` — resolved relative to cwd (`REPO_ROOT` = `demo/`), so this becomes `demo/modules/l2//manifest.yaml` ✓. - `L1_SCRIPT="modules/l1/${L1_NAME}/mock_apply.sh"` — becomes `demo/modules/l1//mock_apply.sh` ✓. 3. **`.gitea/workflows/pipeline.yml`** (read at `/root/acdl/.gitea/workflows/pipeline.yml`): uses `python3 scripts/policy_checker.py`, `scripts/confidence_signal.py`, `scripts/evidence_writer.py`, `scripts/finalize_evidence.py`, `scripts/mock_executor.sh` — all relative to the checkout root. After the move, the checkout root for the *demo* workflow is `demo/` (if the workflow is moved to `demo/.gitea/workflows/`), OR the workflow stays at `.gitea/workflows/` and references `demo/scripts/...` (if the workflow is kept at the repo root for the demo to be runnable from the repo root). **Decision: move the demo workflows to `demo/.gitea/workflows/` so the demo is fully self-contained under `demo/`.** The v1.0 demo's Gitea Actions runs are historical (tag `v1.1.0`); future demo re-runs are local (`run_demo.sh --no-upload`), not on Gitea Actions. 4. **`scripts/verify_phase01..05.sh`**: these verify the demo's phases. After the move, they run from `demo/scripts/` and reference `modules/`, `scripts/`, `evidence-ui/` relative to `demo/`. The verify scripts use the same `REPO_ROOT`-relative pattern. They will continue to work from `demo/` because all paths are relative to the script's parent. 5. **`evidence-ui/index.html`**: fetches `audit.json` from a relative URL (the v1.0 demo's `acdl-evidence` raw URL). No path fixup needed; the UI is static. ### Path fixups needed **None.** All demo scripts resolve paths via `SCRIPT_DIR`/`REPO_ROOT` relative-to-script, which auto-adjusts when the demo moves to `demo/`. The only thing to verify (in Phase 06's `verify_phase06.sh`) is that `demo/scripts/run_demo.sh --no-upload` exits 0 from `demo/` — but per the task instructions, this is *static analysis only*; Phase 06 executes the regression check. ### Confidence 0.90 — the static analysis is thorough; the 0.10 residual is for an undetected absolute path in a script I did not read (e.g. `scripts/gitea_setup.sh`, `scripts/finalize_evidence.py`). ### Assumptions logged - **A-10.1** (0.90): the demo's scripts use only `SCRIPT_DIR`/`REPO_ROOT` relative paths; no absolute paths. (Verifyable by a `grep -n '/'` pass in Phase 06.) - **A-10.2** (0.85): moving `.gitea/workflows/pipeline.yml` to `demo/.gitea/workflows/pipeline.yml` does not break any *historical* Gitea Actions run (those are pinned to tag `v1.1.0` and immutable). ### Recommendation — file-move list | From (repo root) | To | |------------------|----| | `modules/` | `demo/modules/` | | `scripts/` | `demo/scripts/` | | `evidence-ui/` | `demo/evidence-ui/` | | `contracts/` | `demo/contracts/` | | `.gitea/workflows/pipeline.yml` | `demo/.gitea/workflows/pipeline.yml` | | `.gitea/workflows/.gitkeep` | `demo/.gitea/workflows/.gitkeep` | | `ACDL_DEMO.md` | `demo/ACDL_DEMO.md` | | `contracts-repo/` | `demo/contracts-repo/` (if it is demo-only; verify in Phase 06) | | `runner-data/` | `demo/runner-data/` (if it is demo-only; verify in Phase 06) | **New top-level dirs (scaffolded empty in Phase 06):** `platform/`, `schemas/`, `adapters/`, `terraform/`, `modules-ir/`. **README rewrite:** reflect the real platform (vision + architecture links, new layout); the demo README moves to `demo/README.md` (or `demo/ACDL_DEMO.md`). **Path fixups in `demo/scripts/*.sh` and `demo/.gitea/workflows/*.yml`:** *none* (all paths are relative-to-script). The only fixup *may* be in `demo/.gitea/workflows/pipeline.yml`'s `ref: milestone/v1.0-initial` (the demo branch) — if the demo is re-run on Gitea Actions (it won't be; the demo is local-only post-archive), but this is moot for the archive. --- ## Risks (highest-risk items that could block Phase 08–10, ranked) 1. **R-1 (HIGHEST): Gitea OIDC gap → spike must use a key-rotation waiver.** - **Risk:** Phase 08 cannot configure real AWS OIDC trust (TARGET 1). The spike falls back to D-039 (per-run-rotated long-lived key). - **Mitigation:** adopt D-039 (this research's recommendation); Phase 08 implements `scripts/rotate_spike_key.sh`; the v1.2 path tracks go-gitea/gitea#36988. - **Impact if unmitigated:** Phase 08's success criterion ("a workflow step assumes the role via OIDC with no long-lived credential") is *unachievable* in this environment. The waiver is the only path. 2. **R-2: Checkov `terraform_plan` framework edge cases on the spike's minimal S3 plan.** - **Risk:** Checkov's `terraform_plan` scanner ignores a few checks (CKV_AWS_217, 233, 237 — per the plan-scanning doc) that rely on `lifecycle` blocks not present in plan JSON. The spike's S3-only plan may surface fewer checks than expected, making the "all six inputs present" gate artificially pass. - **Mitigation:** the adapter (TARGET 4) emits the `ACDL_TAG_NAMING` skipped record to guarantee the policy input is non-empty; Phase 10's `verify_phase10.sh` asserts the confidence signal's `perInput.policy` is present and non-null. - **Impact if unmitigated:** the spike could pass without exercising the policy path meaningfully. 3. **R-3: The 6 confidence-signal inputs are a *recommendation*, not locked.** - **Risk:** the architecture (§8) does not enumerate the 6 inputs; this research chose them (policy, validation, freshness, source, history, nfrs). If lead-developer or security-engineer disagrees, Phase 07 re-opens the design. - **Mitigation:** surface as D-040 (this research's recommended 6 + weights); Phase 07 adopts or amends. - **Impact if unmitigated:** Phase 07 scope creep; the spike's confidence signal (Phase 10) blocks on the enumeration. 4. **R-4: `gitea-runner` rename (`act_runner` → `gitea-runner`).** - **Risk:** the v1.0 docs and the architecture refer to `act_runner`; the runner was renamed in 2026-04 (gitea/runner#850). The binary is now `gitea-runner`, the image `gitea/runner`. Documentation drift. - **Mitigation:** Phase 07 updates ARCHITECTURE.md to use `gitea-runner` (the current name) with a note that v1.0 used `act_runner`. - **Impact if unmitigated:** confusion in Phase 08 operator docs; low impact. 5. **R-5: Demo archive may have an undetected absolute path.** - **Risk:** TARGET 10's static analysis may have missed an absolute path in a script I did not read (`gitea_setup.sh`, `finalize_evidence.py`, `l3b_agent_stub.py`). - **Mitigation:** Phase 06's `verify_phase06.sh` runs `demo/scripts/run_demo.sh --no-upload` and asserts exit 0; a failure is caught there. - **Impact if unmitigated:** Phase 06 regression failure; fixable in-phase. 6. **R-6: S3 Object Lock + JWS deferred to v1.2 may be challenged.** - **Risk:** REQ-20 says "tiered audit ledger design is authored" — this is satisfied by Phase 07 design doc. But if a reviewer reads REQ-20 as "implemented," the spike falls short. - **Mitigation:** PROJECT.md's REQ-20 wording is "design authored"; Phase 07 produces the design; v1.2 implements. Re-confirm in Phase 07's success criteria. - **Impact if unmitigated:** milestone audit debate; resolvable by pointing at the design doc. --- ## Decisions surfaced (proposals for PROJECT.md adoption by lead-developer) | ID | Decision | Rationale | Confidence | Alternatives | |----|----------|-----------|------------|--------------| | **D-039** | Spike-only waiver: per-run-rotated long-lived AWS key (D-034's bootstrap key, rotated after each spike run by `scripts/rotate_spike_key.sh`). OIDC federation deferred to v1.2, blocked on go-gitea/gitea#36988. | Gitea Actions does not support `id-token: write` / OIDC token issuance (TARGET 1). The waiver satisfies §12.5's *intent* (no persistent long-lived key) for the spike; v1.2 implements real OIDC when the Gitea PR merges. | 0.95 | (b) self-hosted OIDC broker [heavy for a spike]; (d) scheduled credential mint [reintroduces upstream long-lived key]; (e) LocalStack [invalidates REQ-23]. | | **D-040** | The 6 confidence-signal inputs are: policy (0.30), validation (0.25), freshness (0.10), source (0.15), history (0.10), nfrs (0.10). Weights frozen for v1, tuned in v1.2 alongside thresholds (BA.B). | The architecture (§8) locks "six canonical inputs" but does not enumerate them. This research's enumeration covers the platform-computable subset present in every environment (including dev). | 0.75 | Different input set (e.g. split "validation" into schema/IR/plan); different weights. | | **D-041** | Spike audit ledger = v1.0 hash chain + DynamoDB outbox + `acdl-evidence` mirror. S3 Object Lock (compliance mode, 7-yr) + JWS (platform-level KMS signing key, rotated quarterly) + daily checkpoints are *v1.2* build-out, authored as design in Phase 07 (`platform/audit_ledger_design.md`). | REQ-20 is "design authored," not "implemented." The spike proves the outbox write path; the regulatory ledger is a v1.2 build. | 0.85 | Implement Object Lock in the spike (scope creep; the spike's goal is the IR commitments, not the ledger). | | **D-042** | HITL approver identity in Gitea = `gitea.actor` of the `workflow_dispatch` run that sets `approve_qa=true` / `approve_prod=true` / `approve_dr=true`. Separation-of-duties reads `approver_qa` from the DynamoDB outbox and compares to the prod-dispatch `gitea.actor`. | Gitea has no Environments API and ignores `environment:` blocks (v1.0 D-013; re-confirmed). `gitea.actor` is the only available approval-identity signal. | 0.85 | Self-hosted approval portal (over-engineering for v1). | | **D-043** | Tag/naming compliance is deferred for the spike: the Checkov adapter emits a single `SKIPPED` PolicyCheckResult (`ruleId: ACDL_TAG_NAMING`, `severity: info`) so the confidence signal's policy input is non-empty. A custom Checkov YAML rule for tag presence lands in v1.2. | Checkov has no built-in tag-presence check for general AWS resources. Writing a custom Checkov rule in the spike is scope creep; the spike's goal is the IR + adapter path. | 0.80 | Write the custom Checkov rule in the spike (adds a Phase 09/10 dependency on Checkov's custom-rule API). | | **D-044** | DynamoDB outbox mode = `PAY_PER_REQUEST` (on-demand); PK `contractId`, SK `eventType#eventTs`; TTL `expire_at` = now + 365d. No separate async worker / DLQ in the spike (RTO = workflow re-run); the v1.2 outbox worker + DLQ is a Phase 07 design artifact. | On-demand is the zero-cost-at-idle spike mode; the spike is a single dev-only submission. | 0.85 | Provisioned capacity (overkill for the spike); separate Lambda worker (v1.2 scope). | | **D-045** | Runner image tooling: `runs-on: ubuntu-latest` (default `gitea/runner-images:ubuntu-latest`); install `terraform` via HashiCorp apt repo (pin `1.9.*`), `checkov` via pip (pin `>=3.2,<4`), use `--break-system-packages` for the pip install inside the ephemeral container. | Neither tool is pre-installed on the default runner image (TARGET 2). Pinning avoids mid-spike version drift. | 0.85 | `tfenv` (extra shell-init step, unnecessary for single-version spike); `gitea/runner-images:ubuntu-latest-full` (amd64-only, large, not needed). | | **D-046** | `act_runner` → `gitea-runner` rename: Phase 07 updates ARCHITECTURE.md and PROJECT.md to use the current name `gitea-runner` (formerly `act_runner`, renamed 2026-04 in gitea/runner#850). The binary is `gitea-runner`; the image is `gitea/runner`. | Naming drift between v1.0 docs and the current runner. | 0.90 | Keep `act_runner` (stale). | --- ## v1.2 Research Addendum (Phase 11, 2026-07-21) > Phase: research (Phase 11). Milestone: v1.2. Status: active. > Researcher: ci-researcher (inline, docs phase). Autonomy: full. > Sources: GitHub API (go-gitea/gitea#36988), ACDL codebase audit > (`terraform/bootstrap/`, `scripts/`, `adapters/terraform/`, > `modules-ir/registry.json`, `.ciagent/VERIFY.md`, `.ciagent/PERSONAS.md`). > Scope: re-eval OIDC blocker, NFR audit of the v1.1 spike, simplification > opportunities, README rewrite plan, ECS L1 catalog scoping. ### TARGET 9 — go-gitea/gitea#36988 re-check (v1.2) **Verdict (conf 0.95): still open, not merged.** Re-checked 2026-07-21 via `api.github.com/repos/go-gitea/gitea/pulls/36988`: - `state`: open - `merged`: false - `merged_at`: null - `updated_at`: 2026-05-27T16:26:24Z - `title`: "Add Actions OIDC provider with workflow permission gating" No movement since the v1.1 research (2026-07-21 v1.1 research also found it open). Real OIDC federation remains impossible for Gitea Actions. **D-047 adopts**: extend the D-039 per-run-rotated-key waiver for v1.2; real OIDC is deferred to v1.3+. The waiver continues to satisfy §12.5's *intent*: no *persistently* long-lived key (`scripts/rotate_spike_key.sh` rotates after each run; Phase 12 tightens IAM scoping + rotation hygiene). ### TARGET 10 — NFR audit of the v1.1 spike Audited the v1.1 spike's operational code for NFR gaps. **`terraform/bootstrap/spike_runner_policy.json`** — least-privilege PASS already. Explicit Allow list (S3 state bucket R/W, DynamoDB outbox R/W, `sts:GetCallerIdentity`) + `DenyEverythingElse` on `*` with `NotResource`. No wildcards in the Allow statements. **v1.2 gap**: the policy only covers S3 + DynamoDB + STS — Phase 15's `terraform apply` to ECS needs ECS + ECR + ELB + IAM (plan + apply) permissions added. Phase 12 scopes the policy expansion; Phase 15 applies it. **`terraform/bootstrap/create_state_backend.py`** — idempotent PASS already. `head_bucket` → skip-create if exists; `describe_table` → skip-create if exists; `put_bucket_versioning` is idempotent. **No v1.2 change needed.** **`terraform/bootstrap/create_iam_user.py`** — idempotent PASS already. `get_user` → skip-create if exists; `put_user_policy` overwrites (idempotent); `list_access_keys` → skip-create if an active key exists. **No v1.2 change needed.** **`scripts/run_spike_plan.sh` + `scripts/run_spike_e2e.sh`** — two scripts, overlapping setup (env loading, `cd terraform/spike`, `terraform init`). `run_spike_e2e.sh` is the superset (full pipeline); `run_spike_plan.sh` is the plan-only subset. **v1.2 simplification (Phase 12)**: consolidate into one `scripts/run_platform.sh` with a `--plan-only` flag (default: full e2e). Removes ~30 lines of duplication. **`scripts/rotate_spike_key.sh`** — idempotent PASS (always ends with exactly 1 active key). Uses the bootstrap root key to rotate; documented that D-034 closure (root key deactivation) is a manual user step. **No v1.2 change needed** (the root key is now deactivated per D-034 closure; rotation uses the spike key itself or a separate rotation credential — flagged as a v1.2 operational note in Phase 12). **Error handling**: `run_spike_e2e.sh` uses `set -u` + a `fail()` helper — good. `run_spike_plan.sh` uses `set -u` + inline exits — adequate. The consolidated `run_platform.sh` should use `set -euo pipefail` + `fail()` for uniform strictness. **P1-1 redaction target (Phase 12 — DONE)**: the v1.1 `.ciagent/` audit narrative referenced two AWS access key IDs (`AKIA…SPIKE` rotated spike key, `AKIA…ROOT-DEACTIVATED` deactivated root key). Public identifiers, not secret pairs, in the audit narrative not executable code. **Phase 12 redacted** them to `AKIA…SPIKE` / `AKIA…ROOT-DEACTIVATED` across `.ciagent/RESEARCH.md`, `PROJECT.md`, `REVIEW.md`, `AUDIT.md`. **P1-B stale paths**: `.ciagent/PERSONAS.md` line 47 still has `platform/registry/**` (the rest were fixed at `ab69d10`). **Phase 12 fixes** line 47 to `acdl_platform/registry/**` (or removes it — there is no `acdl_platform/registry/` dir; the registry is `modules-ir/registry.json`). ### TARGET 11 — Simplification opportunities 1. **Script consolidation** (above): `run_spike_*.sh` → `run_platform.sh`. 2. **`terraform/spike/.terraform/` artifacts**: gitignored already (`.gitignore` covers `.terraform/`, `.terraform.lock.hcl`, `tfplan`, `*.tfstate*`). No change. 3. **`acdl_platform/__pycache__/`**: gitignored already. No change. 4. **Dead code**: none found — the spike is tight. The `run_spike_plan.sh` script is the only redundancy (subsumed by `run_platform.sh --plan-only`). 5. **`demo/` archive**: correctly separated; no v1.2 touch. ### TARGET 12 — README rewrite plan Current `README.md` (51 lines) is stale: "v1.1 (active)" framing, no "how to run the platform" section, no v1.2 objective. **Phase 11 rewrites it** to reflect: - v1.1 complete (tag `v1.2.0`); v1.0 demo archived under `demo/`. - The actual spike flow: contract → IR → `terraform plan` → Checkov → confidence signal → outbox. - How to run: `scripts/run_platform.sh` (after Phase 12; for now `scripts/run_spike_e2e.sh`). - Real repo layout table (the existing one is accurate; refresh the "Populated" column). - v1.2 objective (platform hardening + ECS microservice). ### TARGET 13 — ECS L1 catalog scoping (for Phase 13) Six L1s needed for an ECS Fargate microservice. Each maps to one or more AWS Terraform resources; the adapter `TYPE_MAP` (currently `{"aws:s3:bucket": "aws_s3_bucket"}`) needs expansion: | L1 | IR type(s) | Terraform resource(s) | Key inputs | |----|-----------|----------------------|-----------| | `l1-vpc` | `aws:ec2:vpc`, `aws:ec2:subnet`, `aws:ec2:routetable` | `aws_vpc`, `aws_subnet`, `aws_route_table` + associations | cidr, azs | | `l1-ecs-cluster` | `aws:ecs:cluster` | `aws_ecs_cluster` | name | | `l1-ecs-service` | `aws:ecs:service`, `aws:ecs:task_definition` | `aws_ecs_service`, `aws_ecs_task_definition` | image, port, cpu, memory, env | | `l1-iam-role` | `aws:iam:role`, `aws:iam:rolepolicyattachment` | `aws_iam_role`, `aws_iam_role_policy_attachment` | task + exec role | | `l1-alb` | `aws:elbv2:loadbalancer`, `aws:elbv2:listener`, `aws:elbv2:targetgroup` | `aws_lb`, `aws_lb_listener`, `aws_lb_target_group` | port, protocol | | `l1-ecr` | `aws:ecr:repository` | `aws_ecr_repository` | name | The IR schema (`schemas/ir.schema.json`) is substrate-agnostic and already supports arbitrary resource types — no schema change needed, only new `interface.json` files + `TYPE_MAP` entries. The `l2-microservice` thin-composition references all six (depth ≤ 5). ### Decisions surfaced (v1.2) | ID | Decision | Rationale | Confidence | Alternatives | |----|----------|-----------|------------|--------------| | **D-047** | Extend D-039 per-run-rotated-key waiver for v1.2. Real OIDC deferred to v1.3+. | go-gitea/gitea#36988 still open (TARGET 9). The waiver satisfies §12.5's intent for v1.2; Phase 12 tightens IAM + rotation hygiene. | 0.95 | (a) wait for #36988 (blocks v1.2 indefinitely); (b) self-hosted OIDC broker (heavy); (c) KMS-backed ephemeral creds (scope creep for v1.2). | | **D-048** | Consolidate `run_spike_plan.sh` + `run_spike_e2e.sh` → one `scripts/run_platform.sh` with `--plan-only` flag (default: full e2e). | Two scripts with overlapping setup (~30 lines duplicated). One script with a flag is simpler and matches the "streamline" scope axis. | 0.90 | Keep both (redundant); delete `run_spike_plan.sh` only (loses the plan-only convenience). | | **D-049** | v1.2 L1 catalog = 6 L1s (`l1-vpc`, `l1-ecs-cluster`, `l1-ecs-service`, `l1-iam-role`, `l1-alb`, `l1-ecr`). The adapter `TYPE_MAP` expands to 9 IR types (3 new for VPC, 3 for the rest). | Minimal set to deploy an ECS Fargate service end-to-end. VPC is split into vpc/subnet/routetable because the IR models one resource per `interface.json` entry, but the L1 groups them. | 0.85 | Fewer L1s (e.g. fold VPC into the ECS service — violates L1 single-purpose); more L1s (e.g. separate `l1-securitygroup` — scope creep for v1.2). | --- ## v1.8 Research Addendum > Phase: research (pre-Phase 28). Milestone: v1.8. Status: active. > Researcher: ci-researcher. Autonomy: full. > Sources: web (uptime-kuma GitHub, Terraform docs, AWS KMS docs, AWS > ECS Fargate docs, GitHub Actions docs) + ACDL codebase analysis. ### RESEARCH TARGET 1 — uptime-kuma deployment on ECS Fargate **Verdict: ECS Fargate is the most cost-effective cloud-native option for deploying uptime-kuma, consistent with the existing platform primitives (ecs-cluster, ecs-service, alb).** Findings (verified 2026-07-22): 1. **uptime-kuma Docker image:** `louislam/uptime-kuma:1` (v1) or `louislam/uptime-kuma:2` (v2, latest stable 2.4.0 as of 2026-05-31). The container listens on port 3001. Data is stored in `/app/data` (SQLite + uploaded files). NFS is not supported for the data volume; EFS is the AWS-native equivalent and works with ECS Fargate. 2. **Monitoring capabilities:** HTTP(s), TCP, HTTP(s) Keyword, HTTP(s) JSON Query, WebSocket, Ping, DNS Record, Push, Steam Game Server, Docker Containers. 20-second intervals minimum. Certificate info. Proxy support. 2FA support. 3. **Notification services (90+):** Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), Microsoft Teams (via webhook), and many others. For the ACDL primitive, we expose: Teams webhook, email (SMTP), SMS (via SNS or an external gateway), and GitHub issues (via the GitHub API). 4. **ECS Fargate deployment shape:** - Task definition: 1 container (`louislam/uptime-kuma:1`), port 3001, CPU 256 (.25 vCPU), Memory 512 (.5 GB) — minimal cost (~$5/mo at us-east-1 on-demand pricing for .25 vCPU + .5 GB running 24/7). - EFS volume for `/app/data` (persistent storage across task restarts; Fargate + EFS is the standard pattern for stateful containers). - ALB + listener for a stable public URL (the uptime dashboard). - CloudWatch log group (encrypted with the per-stack CMK). 5. **Endpoint seeding:** uptime-kuma has a REST API (socket.io-based). The platform can seed monitors by either: - (a) Passing `UPTIMA_KUMA__monitors` env var (JSON array) consumed by a startup script — but uptime-kuma does not natively read env for monitor config. - (b) A post-deploy seeding script that calls the uptime-kuma API (`POST /api/monitor`) to create monitors from the `monitored_endpoints` input. This is the cleaner approach — the platform runs a Python script after the ECS service is up that creates monitors via the API. - **Recommendation:** (b) — a `scripts/seed_uptime_monitors.py` that reads the `monitored_endpoints` from the stack outputs + calls the uptime-kuma API. This is testable offline (mocked API) and decouples container startup from monitor configuration. 6. **Separate terraform state:** The uptime stack uses a separate S3 key prefix (`uptime/{consumerRepo}/{contractId}/`) so it is independent of the consumer stack's state. The uptime stack has its own VPC + ALB + ECS cluster (or shares the consumer's — design decision: **separate** to avoid state coupling, per the requirement "separate terraform run, with a separate state"). 7. **Feature flag:** The `feature_flag_enabled` input (set from the consumer contract `inputs.uptime_enabled`, default true) controls whether the `deploy-uptime` pipeline stage runs. When false, the stage is skipped entirely (no resources emitted, no API calls). ### RESEARCH TARGET 2 — Terraform prevent_destroy lifecycle **Verdict: `lifecycle { prevent_destroy = true }` is the correct Terraform mechanism for deletion protection. It prevents `terraform destroy` from destroying the resource without first setting `prevent_destroy = false`.** Findings (verified 2026-07-22): 1. **`prevent_destroy`** is a meta-argument inside a `lifecycle {}` block within a resource. When set to `true`, any Terraform plan that would destroy the resource will fail with an error. To destroy, the user must first set `prevent_destroy = false` and apply, then destroy. 2. **This is exactly the 2-step decommission pattern the user requested:** Step 1: set `deletion_protection = false` (which the adapter translates to `prevent_destroy = false`) + apply. Step 2: set all counts to 0 + apply (which destroys the resources now that prevent_destroy is false). 3. **Adapter emission:** The adapter should emit `lifecycle { prevent_destroy = true }` inside each resource block when the `deletion_protection` NFR is true. When false, omit the `lifecycle` block (or set `prevent_destroy = false`). This is a per-resource meta-argument, not a provider-level setting. 4. **RDS special case:** RDS already has a `deletion_protection` argument on `aws_db_instance` (not a lifecycle meta-arg). The adapter should emit BOTH: the `deletion_protection` argument (for the RDS API-level protection) AND `lifecycle { prevent_destroy = true }` (for the Terraform-level protection). This is defense-in-depth. ### RESEARCH TARGET 3 — AWS KMS key rotation **Verdict: `enable_key_rotation = true` on `aws_kms_key` enables automatic annual rotation (AWS rotates the key material annually). For 90-day rotation, a custom key rotation policy is needed (AWS managed rotation is annual only; 90-day requires a manual rotation schedule or a custom multi-region key + rotation Lambda).** Findings (verified 2026-07-22): 1. **`aws_kms_key`** with `enable_key_rotation = true` enables AWS's automatic key material rotation. AWS rotates the backing key material annually (365 days). This is the simplest option and is the AWS best practice for most use cases. 2. **90-day rotation:** AWS does not support custom rotation periods for managed keys. To achieve 90-day rotation: - (a) Use `aws_kms_key` with `enable_key_rotation = true` (annual AWS-managed rotation) + a CloudWatch Events rule that triggers a Lambda every 90 days to create a new key + update the alias. This is complex and overkill for v1.8. - (b) Accept annual AWS-managed rotation as the default and document that 90-day rotation requires a custom rotation pipeline (roadmap item). The `enable_key_rotation = true` is the v1.8 implementation; the 90-day requirement is a roadmap enhancement. **Recommendation:** (b) — `enable_key_rotation = true` (AWS-managed annual rotation) as the v1.8 implementation. The 90-day requirement is documented as a roadmap item (custom rotation Lambda). The NFR `enable_rotation` (default true) controls the `enable_key_rotation` argument. This is pragmatic; annual rotation is AWS's best practice and 90-day is a future enhancement. 3. **Per-stack CMK pattern:** Each L2 deployment creates its own `aws_kms_key` + `aws_kms_alias` (alias/acdl--). The key is tagged with `acdl:owner` + `acdl:environment`. All primitives in the stack reference this key via `kms_key_arn`. No shared keys across stacks. 4. **Managed KMS fallback:** When a primitive is deployed standalone (L1 without an L2 CMK), the adapter uses `alias/aws/` (e.g. `alias/aws/s3`, `alias/aws/rds`). This is the AWS-managed key for that service. The adapter emits a stderr warning when falling back. The `kms_key_arn` input is optional; the `encryption_enabled` NFR defaults to true. ### RESEARCH TARGET 4 — Forge-agnostic API URLs (P1-9) **Verdict: GitHub and Gitea have compatible issue APIs but different search endpoints. A `GITHUB_API_BASE` env var + `_forge_type()` helper branches the search URL.** Findings (verified 2026-07-22): 1. **GitHub API:** `https://api.github.com/search/issues?q=...` for search; `https://api.github.com/repos/{owner}/{repo}/issues` for create; `https://api.github.com/repos/{owner}/{repo}/issues/{n}/comments` for comments. 2. **Gitea API:** `https://git.cloudinit.dev/api/v1/repos/{owner}/{repo}/issues?...` for search (no `/search/issues` endpoint — issues are listed via the repo issues endpoint with query params); `https://git.cloudinit.dev/api/v1/repos/{owner}/{repo}/issues` for create; `https://git.cloudinit.dev/api/v1/repos/{owner}/{repo}/issues/{n}/comments` for comments. 3. **Detection:** If `GITHUB_API_BASE` contains `/api/v1`, it's Gitea; otherwise it's GitHub. The `_forge_type()` helper returns `"gitea"` or `"github"` based on this. The search URL is branched accordingly; the create + comment URLs are the same pattern (`{base}/repos/{owner}/{repo}/issues`). 4. **Auth:** Both use `Authorization: token ` header. GitHub also accepts `Authorization: Bearer `; Gitea uses `token`. The existing `token` header works for both. ### RESEARCH TARGET 5 — DynamoDB as CMDB for change requests **Verdict: A DynamoDB `acdl-change-requests` table is consistent with the existing platform Lambda + DynamoDB pattern (D-051). The `validate_change_request` Lambda action queries the table + asserts status=approved.** Findings (verified 2026-07-22): 1. **Table schema:** PK `changeRequestId` (string), SK `submittedAt` (string). Attributes: `consumerRepo`, `contractId`, `status` (enum: `requested|approved|rejected|executed`), `requestedBy`, `approvedBy`, `submittedAt`, `executedAt`. 2. **Validation flow:** The decommission pipeline's `validate-change-request` stage invokes the Lambda with `action: validate_change_request`, `changeRequestId: `, `consumerRepo: `. The Lambda queries the table; if the item exists + `status == "approved"` + `consumerRepo` matches, returns 200 with the CR details. Otherwise returns 403. 3. **Terraform:** Add the table to `terraform/platform/main.tf` with SSE via the platform CMK + point-in-time recovery (matching the `acdl-contracts` table pattern from D-051). ### RESEARCH TARGET 6 — Module engineering standards (scan of current modules) **Verdict: The current modules follow a consistent pattern that can be codified into standards. Key patterns identified:** 1. **L1 required files:** `interface.json`, `instance.json`, `README.md`, `examples/simple.yaml`, `examples/complex.yaml`. Multi-resource L1s add `resources[]` + `intra_refs[]` to `interface.json`. 2. **L2 required files:** `composition.json`, `README.md`, `examples/simple.yaml`, `examples/complex.yaml`. No `instance.json`. 3. **Interface shape:** `name`, `version`, `kind` ("l1"|"l2"), `type` (L1 only, `aws::`), `description`, `inputs` (object keyed by name), `outputs` (object keyed by name), `nfrs` (object keyed by name). Multi-resource L1s add `resources[]` (array of `{type, description, inputs[], outputs[]}`) + `intra_refs[]` (array of `{from, to}`). 4. **Input shape:** `{type, description, required, [default], [enum]}`. Output shape: `{type, description}`. NFR shape: `{type, description, default}`. 5. **NFR conventions (v1.8 additions):** Every L1 MUST have `deletion_protection` (boolean, default true) + `encryption_enabled` (boolean, default true) NFRs. L2 modules MUST expose `features.deletion_protection` (default true) + `features.uptime_enabled` (default true). 6. **Registry:** Every module MUST be registered in `modules/registry.json` at its semver. Entry: `{"interface": "", "published_at": "", "deprecated": false}`. 7. **Adapter extension:** 3-table pattern (TYPE_MAP + INPUT_MAP + OUTPUT_MAP) + specialized `_emit_resource` branches for complex resources (nested blocks like `origin {}`, `rules {}`, `default_cache_behavior {}`). 8. **README structure:** `# `, `## Resources`, `## Inputs`, `## Outputs`, `## NFRs`, `## Usage`, `## Compliance extension points`, `## Examples`, `## Versioning`. 9. **Catalog index gap:** `modules/README.md` Primitives table is missing `rds` (flagged during scan). Must be fixed in Phase 35. ### Decisions surfaced (v1.8) | ID | Decision | Rationale | Confidence | Alternatives | |----|----------|-----------|------------|--------------| | **D-073** | uptime-kuma v1 (`louislam/uptime-kuma:1`) as the default container image. | v1 is stable + widely deployed. v2 (2.4.0) is newer but has breaking changes. v1 is the safer default; consumers can override via `container_image` input. | 0.85 | v2 (breaking changes risk); pin to a specific v1 tag (maintenance burden). | | **D-074** | Monitor seeding via post-deploy API script (`scripts/seed_uptime_monitors.py`), not env vars. | uptime-kuma does not natively read env for monitor config. A post-deploy script calling the API is cleaner + testable offline. | 0.90 | Env var config (not supported by uptime-kuma); manual config (defeats automation). | | **D-075** | KMS rotation = `enable_key_rotation = true` (AWS-managed annual). 90-day rotation is a roadmap item (custom rotation Lambda). | AWS does not support custom rotation periods for managed keys. Annual is the AWS best practice. 90-day requires a custom Lambda + CloudWatch Events rule — overkill for v1.8. | 0.80 | Custom rotation Lambda (complex, overkill); no rotation (violates requirement). | | **D-076** | uptime stack = separate VPC + ALB + ECS cluster (not shared with consumer stack). | Requirement says "separate terraform run, with a separate state". Sharing the consumer's VPC/ALB would couple the states. Separate infra is cleaner + isolates the uptime stack's lifecycle. | 0.85 | Share consumer's VPC/ALB (state coupling); use App Runner (new service type). | | **D-077** | EFS volume for uptime-kuma `/app/data` (persistent storage across task restarts). | Fargate + EFS is the standard pattern for stateful containers. NFS is not supported by uptime-kuma, but EFS is NFS-compatible + works with Fargate. | 0.90 | S3-backed (uptime-kuma doesn't support S3); no persistent storage (data lost on restart). | --- ## v1.9 Research Addendum (Phase 0, 2026-07-23) > Milestone v1.9. Researcher: lead-developer. Autonomy: full. The v1.9 > scope is well-grounded in the existing codebase; the research is a > focused addendum covering the four new implementation domains > (interpolation, per-env workflow inputs, Wiz GraphQL, attestation > matrix freshness validation) + the design-doc drift audit. ### RA-1 — Contract interpolation prior art + syntax choice (D-081) **Finding:** Variable expansion in declarative manifests is a solved pattern. Terraform uses `${var.x}` / `${local.x}`; Helm uses `{{ .Values.x }}`; GitHub Actions uses `${{ }}`; CloudFormation uses `!Ref` / `!Sub`. The contract schema is YAML validated by `jsonschema` — the schema does not inspect string *contents*, so any token syntax is schema-safe. **Choice:** `${env.}` + `${contract.}` (D-081). Rationale: - Shell-style `${...}` is the most familiar to the platform's audience (DevOps engineers comfortable with Terraform/HCL). - Dotted paths (`${env.state_backend.bucket}`) mirror Python attribute access and the existing `wire["from"]` syntax (`contract.inputs.x`, `.outputs.y`). - No conflict with YAML (`${}` inside a YAML string is a literal until the resolver expands it) or with `jsonschema` (string content is not schema-constrained). - Jinja `{{ }}` was considered (supports future filters) but rejected — the contract is a data file, not a template; filters would invite logic-in-config anti-patterns. **Implementation shape:** a single `_expand_vars(value, context)` recursive walker in `core/contract_resolver.py`. Context = `{"env": , "contract": }`. Unknown token → `ValueError` with the token text (fail loud, no silent passthrough — consistent with the P1-3 SSM fail-loud precedent). **Confidence:** 0.92. Risk: none — the expansion is post-schema-validation and pre-IR-resolution, so it cannot break the schema or the adapter. ### RA-2 — GitHub Actions `workflow_call` `environment` input + per-env jobs (D-082) **Finding:** GitHub Actions `workflow_call` inputs support `type: string` with no enum constraint at the workflow-call layer (enum constraints exist only for `choice`-typed *workflow_dispatch* inputs). The deploy workflow already uses `workflow_call` with `contract` + `mode` + `changeRequestId` string inputs. Adding an `environment` string input (default empty, validated by `run_platform.sh`) is a one-line addition. **Per-env job pattern:** the consumer repo's *caller* workflow (`.github/workflows/deploy-.yml`) does: ```yaml jobs: deploy-qa: uses: acdl/.github/workflows/deploy.yml@v1.9 with: environment: qa contract: .acdl/static-assets.qa.yaml ``` One caller workflow per environment = one CI job per environment. The `environment:` field in the contract is not edited for promotion; promotion = running the qa caller. The hybrid model (D-082) also lets a single contract be promoted via the `environment` input alone. **Gitea caveat:** Gitea Actions supports `workflow_call` (reuses the GitHub Actions workflow YAML). The `environment` input works identically. Gitea has no Environments API (D-013/D-042) — the HITL gate is the `workflow_dispatch` approval-input fallback (already documented in `hitl_matrix_design.md`). For `workflow_call` (reusable), the caller workflow's `workflow_dispatch` trigger carries the approval input. **Confidence:** 0.90. Risk: the Gitea `workflow_call` + approval-input combination needs the caller to be `workflow_dispatch`-triggered (not `workflow_call`-triggered) for the gate to fire — documented in Phase 41. ### RA-3 — Wiz GraphQL API shape (D-0xx, REQ-110) **Finding:** Wiz exposes a GraphQL API at `/graphql`. Auth = `Authorization: Bearer `. The primary query for issues: ```graphql query IssuesQuery($filterBy: IssueFilter) { issues(filterBy: $filterBy) { nodes { id severity title entity { name type } control { name } createdAt } pageInfo { hasNextPage endCursor } } } ``` Wiz severity enum: `CRITICAL | HIGH | MEDIUM | LOW | INFORMATIONAL`. Mapping to `PolicyCheckResult`: - `engine: "wiz"` - `ruleId: ` (or `WIZ_` fallback) - `severity: ` - `status: FAIL` (Wiz issues are findings; pass = no issues returned) - `message: ` - `resource: <entity.name>` **Graceful degrade:** when `WIZ_API_TOKEN` or `WIZ_API_URL` unset → emit the existing single `SKIPPED` `WIZ_NOT_CONFIGURED` record (no network call). Offline tests use a recorded JSON fixture (no live Wiz tenant). **Confidence:** 0.80. Risk: Wiz API version drift — the query shape is stable as of Wiz API v2 (2026), but the fixture is the test's source of truth, not the live API. ### RA-4 — Attestation matrix freshness validation (D-084, REQ-109) **Finding:** The 8 concerns in `hitl_matrix_design.md` §10.4 have declared freshness windows (24h, 7d, 30d, 90d, 180d). Operator-supplied evidence (load test, DR drill, FinOps forecast, runbook) is uploaded as a signed blob. The matrix validates: 1. **Presence** — the evidence artifact exists for the target env. 2. **Freshness** — `artifact.timestamp` is within the declared window. 3. **Schema** — the artifact matches a per-concern JSON schema (e.g. load-test artifact has `p99_latency`, `throughput`, `pass_rate`). 4. **Signature** (when `ACDL_ATTESTATION_SIGNING_KEY_ID` set) — JWS detached signature verification against a platform KMS key. When unset (dev/CI), signature verification is skipped (offline-testable). **Offline-testable concerns** (run for real, no operator input): - Contract NFRs (the platform's own contract validator). - Schema validity (jsonschema). - Policy pass (Checkov/Wiz/Kyverno `PolicyCheckResult` records). **Operator-supplied concerns** (require uploaded artifact): - Functional correctness (e2e suite report). - Performance baseline (k6/Gatling report). - Security posture (Trivy/Snyk scan + Security signature). - Operational readiness (runbook/dashboard/oncall/alerts). - Incident response (Sev-1 drill record). - Capacity/cost (FinOps forecast). - Resilience (DR drill, chaos report, backup verification). - dr-region deploy (dr drill report). **Confidence:** 0.88. Risk: the signature verification path is only exercised when a signing key is configured (dev/CI skips it); production deployment must set `ACDL_ATTESTATION_SIGNING_KEY_ID`. ### RA-5 — Design doc drift audit (REQ-100, REQ-101) **`core/hitl_matrix_design.md` drift:** - Status block says "v1.2 wires the gates" — stale (v1.9 wires them). - "Spike scope note" says "the spike is dev-only; HITL is not exercised" — stale (v1.9 exercises qa/prod/dr). - §10.4 matrix is presented as design-only — v1.9 implements the offline-testable subset (D-084). - D-042 approver-identity mechanics are still accurate (Gitea has no Environments API; `gitea.actor` is the approver of record). **`core/audit_ledger_design.md` drift:** - "Spike scope (D-041)" says "Phases 08-10 implement" — stale (the outbox is shipped + production since v1.8). - "v1.2 build-out" (S3 Object Lock + JWS + worker + DLQ + checkpoints) never shipped; v1.9 defers it explicitly (D-083). - The outbox item shape is still accurate; the `approver_qa`/ `approver_prod` attributes are populated by v1.9's `hitl_gates.attest`. **Confidence:** 0.95. Risk: none — doc-only. ### RA-6 — P1-1 adapter defaults audit (D-085, REQ-102) **Hardcoded defaults found in `adapters/terraform/adapter.py`:** - `desired_count = 1` (ECS service, 2 occurrences: line 238, 481). - `launch_type = "FARGATE"` (ECS service, line 239, 482). - `family = "app"` (task def, line 254 — reads `inputs.get("family", "app")` so partially parameterized; the `"app"` default should move to the interface). - `target_type = "ip"` (ALB target group, line 274). - `load_balancer_type = "application"` (ALB, line 272). - `Name = "acdl-microservice-rt"` (route table, line 283) + `Name = ...` tags on VPC/IGW (lines 515, 542 `name = "app"`). **Fix:** add `desired_count`, `launch_type`, `family`, `target_type`, `load_balancer_type`, `name` (VPC/IGW/RT) to the corresponding L1 `interface.json` `inputs` with defaults. The adapter reads `inputs.get("<name>", <default>)` — but the resolver should populate the default from the interface so the adapter reads `inputs["<name>"]` with a fallback only for safety. Tests assert an override emits the overridden value. **Confidence:** 0.90. Risk: low — the v1.1 S3 regression test must still pass (S3 has none of these inputs). ### Decisions surfaced (v1.9) | ID | Decision | Rationale | Confidence | Alternatives | |----|----------|-----------|------------|--------------| | **D-087** | Interpolation expansion is recursive over dicts + lists + strings (not just top-level inputs). | A nested input like `env: { DATABASE_URL: "acdl-${env.environment}-db" }` should expand too. | 0.90 | Top-level only (misses nested maps). | | **D-088** | The `environment` workflow_call input overrides the contract's `environment` field *before* schema validation, so the schema sees the overridden value. | Interpolation context depends on the resolved environment; override must happen pre-validation so `${env.environment}` is consistent. | 0.92 | Override post-validation (inconsistent interpolation context). | | **D-089** | Attestation artifact signature verification is skipped when `ACDL_ATTESTATION_SIGNING_KEY_ID` is unset (dev/CI); required for prod/dr. | Offline tests cannot sign with a real KMS key. The skip is explicit + logged. | 0.85 | Always require signature (breaks offline tests). | --- *End of RESEARCH.md v1.9 addendum.*