# Nova — v1.28 Research Findings

> Phase: research (pre-execution). Milestone: v1.28 (CLI Canonicalization
> + Identity Layer). Status: research. Researcher: ci-researcher.
> Autonomy: full.
>
> Research delegated to the ci-researcher subagent (full domain/ecosystem
> research with web citations). This file is the curated summary; the
> full 868-line research document is preserved in git history (the
> subagent's task output). Key findings + recommendations are below.

---

## §1 — Codebase Inventory (grounding)

### 1.1 `core/` modules (the REQ-324 subcommand surface)

19 Python files under `core/` (plus `core/lambda/`, `core/metrics/`).
Two already have `_cli.py` companions (`contract_resolver_cli.py` 40
lines, `regression_verify_cli.py` 32 lines) — the thin-delegate
precedent for `nova/<module>.py`. **No `nova/` dir, no `bin/`, no
`[project.scripts]` entry exists today.** The CLI is greenfield.

### 1.2 Existing Lambda pattern (`core/lambda/contract_ingestor.py`)

521 lines. Function URL + IAM auth (D-051). DynamoDB via lazy
module-global `boto3.resource`. Secrets Manager for tokens. Schema
validation in-Lambda. **`__main__` block already does CLI dispatch**
(`--check-readiness` → `core.submission_readiness.cli_main`) — this is
the dual-use precedent for REQ-329. Local testing via
`core/local_emulators.py:LocalLambdaStub`.

### 1.3 `core/env.py` — getter, not synthesizer

31 lines. `get_env(name, default)` reads `NOVA_<name>` from `os.environ`.
**REQ-330 needs a NEW `synthesize_local_env()` function** added here.
The closest existing pattern is `core/onboarding.py:generate_env_file()`.

### 1.4 `PolicyEngine` Protocol + `KyvernoJsonEngine` (the ABAC substrate)

`core/policy_engine.py`: `PolicyEngine` Protocol with `evaluate(payload,
policy_dir, contract_id) -> list[dict]`. `KyvernoJsonEngine` shells to
`kj scan --policy <dir> --payload <file> --output json`. Policy shape =
`ValidatingPolicy` (`apiVersion: json.kyverno.io/v1alpha1`) with
`spec.rules[].assert.all[].check` using JMESPath. Severity from
`metadata.annotations["nova.cloudinit.dev/severity"]`. **The payload
can be ANY JSON** — not just contracts (the v1.25 design point). This
is what makes kyverno-json usable for ABAC token vending (D-227).

### 1.5 `pyproject.toml` state

name `nova`, version `1.14.0`, requires-python `>=3.10` (spec wants
3.12 — bump needed for REQ-326). setuptools build backend. No
`[project.scripts]`, no `[tool.setuptools.packages.find]` — both needed.
Deps: `boto3`, `jsonschema`, `pyyaml`. No `argon2-cffi`, `cryptography`,
`pyjwt`, `click`/`typer` — **argparse-only** is the repo convention.

### 1.6 Forge conventions

`.github/workflows/` + `.gitea/workflows/` kept byte-identical. Python
3.12 already pinned via `actions/setup-python@v5`. No composite action
exists yet — `nova cli-action` (REQ-326) is greenfield.

### 1.7 IAM baseline (load-bearing for REQ-340)

`.ciagent/IAM_POLICY.md` + `terraform/bootstrap/spike_runner_policy.json`.
The `nova-spike-runner` principal already has KMS (incl. `CreateKey`,
`Sign`, `GetPublicKey`), Lambda (incl. `PublishLayerVersion`), DynamoDB
grants. **New grants needed:** `cloudformation:*` (for `nova idp setup
--apply`) + `codeartifact:*` (for the wheel publish pipeline). Flagged
for P1/P2.

---

## §2 — CodeArtifact + Lambda Layer Pipeline (REQ-323)

**Recommendation:** single CI job on merge to `main` affecting
`core/**`/`adapters/**`/`nova/**`/`pyproject.toml`. Build wheel
(`python -m build --wheel`) → `twine upload` to CodeArtifact → build
layer (`pip install --target layer/python/ dist/nova-*.whl argon2-cffi
cryptography pyjwt`) → `aws lambda publish-layer-version` → record
version mapping in SSM `/nova/layer/nova-cli/version` (CAP-035). If
either publish fails, the job fails (merge blocked, REQ-323 AC).

**Atomicity:** wheel publish is idempotent (pin version to
`<semver>+<sha7>`); layer publish retries on failure. CAP-035 reads the
SSM parameter to verify layer-version ↔ wheel-version match.

**Risks:** CodeArtifact not yet provisioned in `581513795199` (CLARIFY
assumption #1); `codeartifact:*` grant missing. Fallback: Gitea-hosted
wheel index. Layer `--compatible-architectures`: build x86_64 only for
v1.28 (aarch64 only if Graviton Lambda needed).

---

## §3 — CLI Subcommand Architecture (REQ-324)

**Recommendation:** three-layer. `nova/__init__.py` (marker) →
`nova/cli.py` (~80 lines, auto-discovers `nova/<module>.py` via
`pkgutil.iter_modules`, dispatches, emits `cli.invocation` audit event)
→ `nova/<module>.py` (≤50 lines each, exports `add_parser(subparsers)`
+ `run(args) -> int`, delegates to `core/`). Entry point:
`[project.scripts] nova = "nova.cli:main"`. **argparse-only** (no
click/typer — repo convention).

**CAP-034 AST scan:** ≤50 lines; ≤3 function defs; every `ast.Call`
resolves to a `core.` import; no conditionals beyond `if __name__`.

**Subcommand groups:** `nova auth`, `nova idp`, `nova metrics` =
nested subparsers (same pattern, one level deeper).

**setuptools:** add `[tool.setuptools.packages.find]` including `nova`,
`nova.*`, `core`, `core.*`, `adapters.*`.

---

## §4 — Argon2id in Lambda Python 3.12 (REQ-334, D-228)

**Findings:** `argon2-cffi-bindings` v25.1.0 ships `cp39-abi3`
manylinux x86_64 + aarch64 wheels — **ABI-stable, compatible with
Python 3.9..3.13**. Lambda Python 3.12 runs Amazon Linux 2023 (glibc
2.34 ≥ 2.28 required). **The abi3 manylinux wheel loads cleanly.**
Confidence: 0.92.

**D-228 AMENDMENT:** the "pure-Python fallback" clause is **weaker than
stated** — there is no maintained pure-Python Argon2 implementation. A
pure-Python crypto fallback is a **liability** (weaker hashing,
violates INV-16's spirit). Revised recommendation:
1. **Primary:** bundled manylinux abi3 wheel in the `nova-cli` Lambda
   layer. Works. Confidence 0.92.
2. **Fallback:** detect `ImportError` at Lambda cold-start → **fail
   closed** (503, refuse sign-ups). The Lambda health check reports
   C-extension status. **Do NOT ship a pure-Python fallback.**
3. **Escape hatch:** Fargate (~1 week, per CLARIFY Q1).

Lambda memory ≥ 512 MB (Argon2id memory_cost ~20 MB + overhead).

---

## §5 — KMS Asymmetric Signing for OIDC Tokens (REQ-337)

**Recommendation: key spec = `ECC_NIST_P256`, alg = `ECDSA_SHA_256`
(JWS `ES256`).** RSA-2048 is larger + slower; P-256 is RFC 7518's
recommended JWT alg. Signature size 64 bytes (vs RSA 256). JWKS
compactness matters (fetched often).

**The #1 gotcha:** KMS returns DER-encoded ECDSA signatures; **JWS
requires raw r‖s concatenation** (RFC 7515 §3.1.3). The token-vend
Lambda converts via `cryptography.hazmat.primitives.asymmetric.utils.
decode_dss_signature` → `r.to_bytes(32) + s.to_bytes(32)`. ~5 lines.
Flagged for the threat model (REQ-347) + KMS round-trip test (REQ-350).

**Flow:** validate PAT → ABAC eval → build JWT header/payload →
`kms.sign(Message=signing_input, MessageType="RAW", SigningAlgorithm=
"ECDSA_SHA_256")` → DER→raw → JWT. `kid` = KMS key alias.

**Verification:** use `pyjwt` (`jwt.decode` handles JWK→key natively);
`cryptography` only for SPKI→JWK in the JWKS Lambda.

**Rotation:** manual, 90 days (matches D-069 CMK cadence). New key +
re-point alias + JWKS serves both `kid`s during overlap.

---

## §6 — JWKS Endpoint (REQ-338, D-230)

**D-230 confirmed.** Lambda function URL (`AuthType: NONE` — JWKS is
public-key only) + reserved concurrency 10 (max 100 RPS, JWKS is
cached client-side). `Cache-Control: max-age=3600`. Separate tiny
`nova-idp-jwks` Lambda (separation of concerns).

**Custom domain + WAF = OPTIONAL** via `--public-jwks-domain <domain>`
flag on `nova idp setup`. Without it, raw function URL (acceptable for
v1.28 pilot). With it: CloudFront + ACM + WAF rate-based rule (>100
req/5min per IP) + Route53 ALIAS. Adds ~8 CloudFormation resources.

**Defer API Gateway** (D-230) — $3.50/M + complexity for no benefit at
v1.28 volume.

---

## §7 — kyverno-json ABAC Policy (REQ-339, D-227)

**D-227 confirmed.** Policy at `platform/abac/token-vend.policy` =
`ValidatingPolicy` with JMESPath checks against a payload of
`{subject, requested_claims, target_resource, environment, pat_jti,
policy_version}`. Decision logic: any `fail` PCR with severity
`critical` → deny (403 + audit); all pass → allow → KMS sign.

**`policy_version` (D-231):** git SHA of the policy file, baked into
the Lambda layer, recorded in every `token.vend.allowed/denied` audit
event.

**BIGGEST PACKAGING RISK:** the token-vend Lambda needs the `kj` Go
binary (~40 MB) on PATH. Bundle it in the `nova-cli` Lambda layer
(`wget` the Linux amd64 release into `layer/bin/kj`). `KyvernoJsonEngine
.is_configured()` checks `which kj` → `/opt/bin/kj` (layer mount). P2
spike confirms it runs in AL2023 Lambda. Fallback: Fargate. Confidence
0.75 — needs the spike.

---

## §8 — PAT Lifecycle (REQ-342, REQ-343, REQ-344)

**PAT = signed JWT** (KMS-signed, `typ: "developer_pat"` distinguishes
from `nova_oidc_token` per INV-14). Claims: `iss, sub, typ, jti, iat,
exp, roles, owner`.

**`nova-pats` DynamoDB table** (4th table): PK=`jti`, GSI1=`sub` (list
PATs for user), GSI2=`pat_hash` (lookup by hash). Only the hash stored
(not raw PAT). Revoked PATs retained for audit.

**Revocation (D-229 CLARIFIED):** GSIs don't support strongly-consistent
reads. The token-vend Lambda extracts `jti` from the PAT JWT (decode
without verifying — signature verified separately) →
`GetItem(PK=jti, ConsistentRead=True)` on the main table. Satisfies the
60s SLO. Confidence 0.90.

**CLI:** `nova auth login` (session→OIDC token, store locally),
`nova auth revoke --pat <jti>`, `nova auth status` (active credential,
mode, selection_reason). Local file `~/.nova/credentials.json` (0600,
never to stdout, in `.gitignore`). "Most recent wins" (D-226 Q5) =
`active_credential_jti` field.

---

## §9 — `nova idp setup` CloudFormation (REQ-340, REQ-341)

**Template (raw dict → JSON, no troposphere dep):** 2-3 Lambdas, 4
DynamoDB tables (`nova-users`, `nova-sessions`, `nova-password-resets`,
`nova-pats`), KMS key `alias/nova-oidc-signing` (ECC_NIST_P256),
function URLs, IAM roles, optional CloudFront/WAF/ACM.

**`--check`:** validates prerequisites (AWS creds, CFN perms, KMS perms,
layer exists via CAP-035). Prints required IAM policy delta.
**`--apply`:** generate → print to temp file + resource summary →
`$PAGER` → `Apply? [y/N]` → `cloudformation deploy --capabilities
CAPABILITY_IAM`. NFR-10 satisfied by the explicit prompt.
**`--dry-run`:** resource list only, no write.
**`--verify`:** runs the KMS round-trip test (REQ-350).

**New IAM grants needed:** `cloudformation:*`, `iam:CreateRole`/`PassRole`,
`lambda:CreateFunction`/`CreateFunctionUrlConfig`,
`dynamodb:CreateTable`, `kms:CreateKey`/`CreateAlias`, `ssm:PutParameter`.

---

## §10 — GitHub + Gitea Marketplace Composite Action (REQ-326)

**Single `action.yml`** at `.github/actions/nova-cli/action.yml`,
referenced by both GitHub + Gitea via `uses: continuous-intelligence/
acdl/.github/actions/nova-cli@v1.28`. Composite action: `setup-python@v5`
(python 3.12) → CodeArtifact login + `pip install nova` → `nova
${{ inputs.command }}`. `NOVA_CLIENT_MODE` env from input.

**Byte-identical test (REQ-326 AC2):** CI matrix runs the action on
GitHub `ubuntu-latest` + Gitea `act_runner` with same inputs; assert
same stdout/exit code.

**Risk:** Gitea `actions/checkout`/`setup-python` may need Gitea
mirrors (`https://gitea.com/actions/...`). P1 test on the actual Gitea
instance. Confidence 0.70.

---

## §11 — `mode_resolver` Priority (REQ-327, D-226)

**TTY detection: check `sys.stdin.isatty()`** (NOT stdout). Edge 3
(`nova apply | tee log.txt`): stdout piped, stdin is TTY → user is
present → `interactive` (correct). `sys.stdout.isatty()` would
misresolve to `agent`. **`stdin` answers "is a human at a terminal?"**

**Credential type detection:** read `~/.nova/credentials.json` →
`active_credential_jti`'s `type` (`developer_pat`/`nova_oidc_token`).
Both + TTY → `interactive`; + no TTY → `agent` (INV-14).

**Property tests (REQ-349):** `hypothesis` with strategies for
flag/env/cred/tty. Properties: deterministic (INV-13), flag-wins,
invalid-env-ignored, no-silent-fallback (every resolution has a
non-empty `selection_reason`).

**`mode_resolver.py` lives in `core/`** (not `nova/`) so Lambdas could
import it, but **it's CLI-only** — the token-vend Lambda doesn't resolve
modes.

---

## §12 — Persona Assessment

See `.ciagent/PERSONAS.md` for the full YAML roster. Summary:
- **Deactivate** frontend-engineer (no UI) + data-engineer (no data
  pipelines in v1.28).
- **Activate** backend-engineer (Lambda/DynamoDB/KMS/CodeArtifact) +
  lead-developer (plan/review/ship).
- **Add** security-engineer (Argon2id/KMS/ABAC/threat model) +
  cli-engineer (subcommand surface/mode_resolver/argparse/CAP-034).

---

## §13 — Architecture Sketch (ARCHITECTURE.md §12.10)

See `.ciagent/ARCHITECTURE.md` §12.10 (appended this stage). New
greenfield files: `nova/` CLI package, `platform/abac/token-vend.policy`,
`core/mode_resolver.py`, `core/env.py:+synthesize_local_env()`,
`core/lambda/nova_idp_{auth,token_vend,jwks}.py`, `tests/test_*`,
`docs/{operator-guide-idp,developer-guide-auth,threat-model}.md`.

---

## Decisions re-validated / amended

| Decision | Status | Change |
|---|---|---|
| D-226 | re-validated + refined | `sys.stdin.isatty()` is the TTY check (not stdout) |
| D-227 | re-validated | `kj` Go binary bundled in Lambda layer — packaging risk flagged |
| D-228 | **amended** | Pure-Python fallback → fail-closed + Fargate (pure-Python crypto is a liability) |
| D-229 | re-validated + clarified | Strong read on main table PK (`jti`), not GSI (GSIs don't support strong reads) |
| D-230 | re-validated | CloudFront/WAF/ACM made optional via `--public-jwks-domain` flag |
| D-231 | re-validated | `policy_version` (git SHA) in the ABAC payload |

**New recommendations for PLAN/GRILL to formalize (no D-ID yet):**
- KMS key spec = `ECC_NIST_P256`, alg `ES256`; DER→raw ECDSA conversion required.
- `nova-cli` Lambda layer bundles the `kj` Go binary (~40 MB).
- `nova-pats` = 4th DynamoDB table; PK=`jti`, GSI1=`sub`, GSI2=`pat_hash`.
- `sys.stdin.isatty()` is the TTY heuristic.
- `[project.scripts] nova = "nova.cli:main"`; argparse-only.
- `cloudformation:*` + `codeartifact:*` = new IAM baseline grants (P1/P2).

---

## RESEARCH complete

All 11 research questions answered with cited findings + concrete
recommendations + risks. D-228 amended (fail-closed, not pure-Python
fallback). The `kj` binary packaging is the highest-risk item (P2
spike). Next: PLAN.

---

# Nova — v1.29 Research Findings

> Phase: research (pre-execution). Milestone: v1.29 (Reposplit + Identity
> Layer Bring-Live). Status: research. Researcher: ci-researcher.
> Autonomy: full.
>
> Research delegated to the ci-researcher subagent (10 topics — Terraform
> import idempotency, `data.aws_ecr_image` digest resolution,
> `lifecycle.precondition`, CloudFront OAC for Lambda Function URL, WAF
> on CloudFront, ACM DNS validation + Route53 alias, `kj` Go binary
> static build, ECR tag format, codebase inspection, Gitea Actions HITL).
> This file is the curated summary. Key findings + recommendations below.

---

## §1 — Terraform `import` idempotency (REQ-361)

- `terraform import <addr> <id>` reads an existing cloud resource into
  state without modifying it; the resource must have a matching
  `resource` block in config.
- Re-importing an address already in state fails with **`Error: Resource
  already managed by Terraform`** (non-zero exit). The CI import step
  must treat this specific error as idempotent success (grep the
  message, not just exit code) — this is the IMPORT-IDEMPOTENT contract.
- `importable-resources.tf` is a convention (not built-in): a dedicated
  file listing resource addresses imported from the live account (S3
  state bucket, DynamoDB tables, IAM OIDC role, KMS keys) so the import
  surface is enumerable + reviewable.
- Drift detection: `terraform plan -detailed-exitcode` (exit 2 = drift)
  fails the apply; the state bucket is bootstrapped manually then
  imported (never created by Terraform — avoids bootstrapping the
  bootstrapper, Q1/§7.1, D-235).

**Recommendation:** `nova-platform-ops` maintains an
`importable-resources.tf` map; CI import treats "already managed" as
idempotent success; `plan -detailed-exitcode` asserts zero drift.

## §2 — `data.aws_ecr_image` digest resolution (REQ-355, REQ-371)

- `data "aws_ecr_image" "kj_image" { repository_name = …; image_tag = … }`
  resolves the tag to an **immutable `sha256:` digest** via
  `image_digest`.
- ECR tags are mutable by default (a re-push moves a tag → different
  digest). KJ-LOCKSTEP pins on `image_digest`, never the tag.
- `image_uri` = `${data.aws_ecr_repository.kj.repository_url}@${data.aws_ecr_image.kj_image.image_digest}`
  — pinning by `@digest`, not `:tag`. Both Lambda and Fargate reference
  the same data source → same digest by construction.
- `data.aws_ecr_image` reads at plan time; if the tag doesn't exist
  (engineering hasn't published), the data source fails the plan (Q7
  fail-closed).

**Recommendation:** Both image-bearing resources reference a single
`data.aws_ecr_image.kj_image`; `image_uri` = `repo@digest`; LOCKSTEP is
true by construction + the precondition (§3) is a verification.

## §3 — `lifecycle.precondition` — the KJ-LOCKSTEP mechanism (REQ-371)

- **Version correction (D-240):** preconditions introduced in
  **Terraform v1.2.0 (May 2022)**, NOT v1.4+ as the spec implies. The
  ops repo `required_version = ">= 1.2.0"` suffices.
- Syntax: `precondition` block inside `lifecycle { … }` for resources.
  Evaluated **before** the resource action (during planning); a failing
  precondition aborts the **plan** with the custom `error_message`.
- `error_message` is a string expression — can interpolate values:
  `error_message = "KJ-LOCKSTEP: Fargate='${aws_ecs_task_definition.kj.image}' canonical='${data.aws_ecr_image.kj_image.image_digest}'"`.
- Asserting two attributes resolve to the same value:
  ```hcl
  lifecycle {
    precondition {
      condition     = self.image_uri == "${data.aws_ecr_repository.kj.repository_url}@${data.aws_ecr_image.kj_image.image_digest}"
      error_message = "KJ-LOCKSTEP: Lambda image does not match the resolved ECR digest"
    }
  }
  ```

**Pitfalls:** precondition blocks cannot reference `count`/`for_each`
unexpanded resources; both resources must depend on the same data source
(explicit `depends_on` if `image_uri` is computed indirectly).

**Recommendation:** Add `lifecycle { precondition { … } }` to **both**
the Lambda and Fargate task; set `required_version = ">= 1.2.0"`.

## §4 — CloudFront OAC pinning to Lambda Function URL (D-233, REQ-364)

- **Critical:** CloudFront OAC for a Lambda Function URL origin requires
  `AuthType: AWS_IAM` on the Function URL (NOT `AuthType: NONE`). With
  `AWS_IAM`, direct access returns 403 unless SigV4-signed; CloudFront +
  OAC signs requests on the viewer's behalf → CloudFront 200, direct 403
  (INV-18 JWKS-EDGE-ONLY).
- OAC resource: `OriginAccessControlOriginType = "lambda"`,
  `SigningBehavior = "always"`, `SigningProtocol = "sigv4"`. Attach via
  `OriginAccessControlId` on the origin block; HTTPS only.
- Resource-based permission: `aws lambda add-permission --action
  lambda:InvokeFunctionUrl --principal cloudfront.amazonaws.com
  --source-arn <distribution ARN>` — binds the Function URL to the
  specific distribution.
- OAC replaces the deprecated S3-origin OAI; for Lambda origins, OAC is
  the only signing mechanism.

**Pitfall:** if `AuthType: NONE` is left on the Function URL, OAC signing
is ignored and the URL stays public — the 403 guarantee evaporates.

**Recommendation:** JWKS Function URL `authorization_type = "AWS_IAM"`,
`lambda`-type OAC (`SigningBehavior: always`), `lambda:InvokeFunctionUrl`
permission scoped to the distribution ARN.

## §5 — WAF WebACL rate-limit + AWS Managed Rules on CloudFront (REQ-365)

- Rate-based rule: `RateBasedStatement` with `Limit: 3000`,
  `AggregateKeyType: "IP"`, `EvaluationWindowSec: 300` (5-min window;
  accepted values 60/120/300/600). WAF checks ~every 10s.
- AWS Managed Rules Common Rule Set = managed rule group
  `AWSManagedRulesCommonRuleSet` (vendor `AWS`), attached as a separate
  priority from the rate rule.
- CloudFront WebACLs **must** be created in `us-east-1` with
  `Scope = "CLOUDFRONT"` (regional WebACLs cannot associate with
  CloudFront).
- CloudWatch metrics: per-rule `VisibilityConfig.CloudWatchMetricsEnabled
  = true`; S3 access logs via `aws_cloudfront_distribution.logging_config`.

**Recommendation:** WebACL in `us-east-1` `Scope=CLOUDFRONT`; rate rule
(3000/5min/IP) + Common Rule Set; associate to JWKS distribution;
CloudWatch metrics + S3 access logs.

## §6 — ACM cert DNS validation + Route53 alias (REQ-366)

- ACM DNS validation: `aws_acm_certificate` with
  `validation_method = "DNS"`; create `aws_route53_record` for each
  `domain_validation_options` CNAME; `aws_acm_certificate_validation`
  waits on `ISSUED`. For CloudFront, the cert **must** be in
  `us-east-1`.
- Route53 alias: `type = "A"`, `alias { name =
  aws_cloudfront_distribution.jwks.domain_name; zone_id =
  aws_cloudfront_distribution.jwks.hosted_zone_id;
  evaluate_target_health = false }`.
- `route53_record_not_resolvable` failure mode: the alias doesn't
  resolve until CloudFront `status = Deployed` AND ACM cert `ISSUED`. If
  the validation CNAME is mis-created or Route53 is not authoritative,
  the CNAME never validates → cert stays `PENDING_VALIDATION` → alias
  NXDOMAIN.

**Recommendation:** ACM cert in `us-east-1` DNS validation; validation
CNAMEs in the authoritative Route53 zone; `aws_acm_certificate_validation`
gates on `ISSUED`; Route53 A-alias to the distribution. Operator guide
documents the `route53_record_not_resolvable` → check-cert-status
debugging path.

## §7 — `kj` Go binary static build for AL2023 Lambda (KJ-STATIC, REQ-354, REQ-363)

- Build: `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s
  -w" -o kj ./…`. `CGO_ENABLED=0` is load-bearing — no cgo, no dynamic
  libc link.
- `file(1)` must report `ELF 64-bit LSB executable, x86-64, statically
  linked` + absence of `shared library`/`interpreter`. Secondary:
  `readelf -d kj` shows no `NEEDED` entries.
- Base image `public.ecr.aws/lambda/python:3.12-al2023`; copy binary to
  `/opt/kj/kj` `chmod 0555` owned by `sbx_user:1051` (Lambda sandbox
  user, uid/gid 1051 in AL2023). `0555` + immutable-owned prevents
  runtime tampering.
- Lambda handler invokes `subprocess.run(['/opt/kj/kj', 'apply', …],
  capture_output=True, check=True)` — `kj` is a substrate binary, not a
  library; the Python handler is a thin shim. kyverno-json (INV-4) is
  separate + unaffected.

**Pitfall:** `CGO_ENABLED=1` (default on systems with gcc) produces a
dynamically-linked binary; AL2023 glibc mismatch → runtime
`GLIBC_X not found`. `CGO_ENABLED=0` eliminates this.

**Recommendation:** `publish.yml` P1 builds with `CGO_ENABLED=0
GOOS=linux GOARCH=amd64`, asserts `file` reports `statically linked` +
no `shared library` (fail build otherwise), copies to `/opt/kj/kj`
`chmod 0555`, handler calls `subprocess.run(['/opt/kj/kj', 'apply', …])`.

## §8 — ECR image tag format (REQ-354 AC 3) — SPEC CORRECTION (D-239)

- **ECR image tags do NOT allow `+`.** The ECR tag regex is
  `^[a-zA-Z0-9]+(?:[._-][a-zA-Z0-9]+)*$` — permitted chars
  `[a-zA-Z0-9._-]` only; `+` is rejected by `PutImage`/`BatchGetImage`
  with `InvalidParameterException`.
- The spec's tag format `v1.29.x+kj-<sha>` is **invalid** as written.
  Correct format: **`v1.29.x-kj-<sha>`** (replace `+` with `-`).
- The digest is the immutable trust surface regardless of the tag string
  — a re-tag is detectable only via digest mismatch. The tag is a human
  hint, not a security boundary.

**Decision D-239 (spec correction):** REQ-354 AC 3 tag format corrected
to `v1.29.x-kj-<sha>`. Confidence 0.95. Applied to REQUIREMENTS.md
§v1.29 REQ-354 AC (3).

## §9 — Codebase inspection (actual file paths)

| Target | Path | Summary |
|---|---|---|
| `publish.yml` | `.github/workflows/publish.yml` (165 lines) + `.gitea/workflows/publish.yml` mirror | Currently publishes wheel + Lambda layer on `push: branches: [main]` (NOT tag-triggered). P1 must change trigger to `on: push: tags: ['v1.29.*']` + attach Lambda zip + ECR image to GitHub Releases. |
| `nova/idp/setup.py` CFN | `nova/idp/setup.py` (40 lines, thin CLI dispatcher) + `core/lambda/nova_idp_setup.py` (actual CFN logic, importlib-loaded because `lambda` is reserved) | REQ-369 archives to `docs/archive/nova-idp-cfn-v1.28.md`; `--apply` delegates to `terraform apply`. |
| `platform/abac/kj-version.txt` | `platform/abac/kj-version.txt` (2 lines: `v0.0.3` + SHA `4ebb9a19...`) | Already pins `kj` v0.0.3 + source SHA from v1.28 P4. P1 reads this SHA to embed in the ECR tag + verify the build. |
| `.gitea/` scrub targets | `.gitea/workflows/` (7 files) + `scripts/sync_workflows.py` (line 26: `GITEA_DIR`), `scripts/sync_to_nova.sh`, `scripts/rotate_spike_key.sh`, `terraform/bootstrap/`, ~100 `.ciagent/` doc matches | REQ-367 P2 removes `.gitea/`, scrubs `gitea` from `.github/` `docs/` `pyproject.toml` `README.md` `.ciagent/`, asserts `forge_parity_disabled` in CI (D-232). `sync_workflows.py` is the central removal target. |
| Consumer `deploy.yml` | NOT in `acdl/.github/workflows/deploy.yml` (that's the platform reusable workflow). Consumer's deploy.yml is in the `nova-blockchain-exchange` project — documented at `.ciagent/nova-blockchain-exchange/REQUIREMENTS.md` (REQ-314) + `.ciagent/nova-blockchain-exchange/README.md`. | P5 bumps consumer's `uses:` ref `@v1.25` → `@v1.29` in both `.github/workflows/deploy.yml` + `.gitea/workflows/deploy.yml` (consumer's `.gitea/` is out of scope for REQ-367 — that scrub is `acdl/acdl` only) + smoke test. |

## §10 — Gitea Actions HITL approval (REQ-357, TFM-HITL)

- Gitea Actions has **no Environments API** with required reviewers. The
  approval signal is `gitea.actor` (triggering user) +
  `gitea.triggering_actor` (may differ on re-run — the re-dispatcher).
- PR author: `${{ gitea.event.pull_request.user.login }}`. INV-3 check:
  `${{ gitea.triggering_actor }} != ${{ gitea.event.pull_request.user.login }}`
  (use `triggering_actor` for re-run safety).
- Gitea scoped-workflows (v1.27+) supports **required workflows** that
  gate PR merges via status checks — but this gates *merge*, not *apply*.
- The `workflow_dispatch` approve-input pattern (D-042) is the mechanism:
  plan runs automatically on PR; apply is a separate `workflow_dispatch`
  with `approve_apply` input; the apply job asserts INV-3 + fails closed.
- **Codebase precedent:** `core/hitl_gates.py` + `core/separation_of_duties.py`
  (D-042) — `hitl_gates.attest(env, approver)` reads
  `GITHUB_ACTOR`/`FORGE_ACTOR`, writes to DynamoDB outbox;
  `separation_of_duties.check` compares approvers. This is the production
  pattern to extend for `nova-platform-ops` `terraform apply`.

**Pitfalls:** scoped-workflow required-check enforcement needs branch
protection on `main`; a re-run changes `gitea.actor` to the re-dispatcher
— use `gitea.triggering_actor` for the effective approver.

**Recommendation:** `nova-platform-ops` uses `workflow_dispatch`
approve-input pattern (extending `hitl_gates.py`/`separation_of_duties.py`);
plan auto-runs on PR, apply is `workflow_dispatch` with `approve_apply`;
apply job asserts `${{ gitea.triggering_actor }} != ${{ gitea.event.pull_request.user.login }}`;
branch protection on `main` + required scoped-workflow status check.

---

## New decisions for the decision ledger (research-derived)

| D-ID | Title | Confidence | Source |
|---|---|---|---|
| **D-239** | ECR tag format `v1.29.x+kj-<sha>` invalid (`+` not in ECR tag regex) → corrected to `v1.29.x-kj-<sha>` | 0.95 | §8 ECR API PutImage character class |
| **D-240** | `lifecycle.precondition` introduced in Terraform v1.2.0 (not v1.4+); ops repo `required_version = ">= 1.2.0"` suffices | 0.98 | §3 Terraform v1.2.0 CHANGELOG |

Both are spec-vs-reality corrections logged at full autonomy (confidence
≥ 0.60 threshold). D-239 is applied to REQUIREMENTS.md §v1.29 REQ-354
AC (3). D-240 is documented in the operator guide (P4) for the
`nova-platform-ops` `required_version` floor.

---

## RESEARCH complete

All 10 research questions answered with cited findings + concrete
recommendations + risks. Two spec corrections (D-239 ECR tag, D-240
Terraform precondition floor). The highest-risk item is the M1.5
verification gate (Q7 carry-forward — `kj` static build + 3 consecutive
rebuilds in `nova-platform-ops` CI). Next: PLAN.