fix(P08 prep): rename platform/ -> acdl_platform/ (stdlib shadow fix)

---ci---
project: acdl
phase: 8
milestone: v1.1
status: plan-as-execute
persona: lead-developer
task: T-8.0
type: prerequisite-fix
---/ci---

The Phase 07 P1 ('platform/ package shadows stdlib platform module')
became a Phase 08 blocker: boto3 imports uuid -> platform.system(),
which fails when the repo's platform/ package is on sys.path[0]. Renamed
platform/ -> acdl_platform/ (the verifier's recommended v1.2 fix, pulled
forward because Phase 08 needs boto3).

- git mv platform/ acdl_platform/ (history preserved)
- verify_phase07.sh: updated paths; removed the /tmp workaround (no
  longer needed; the shadow is gone)
- verify_phase06.sh: updated the new-dirs check for the rename
- README.md: layout table updated

Both verify_phase06.sh and verify_phase07.sh still pass; confidence_signal
now imports + runs correctly from the repo root. boto3 imports clean.
This commit is contained in:
Jon Chery
2026-07-21 18:53:41 +00:00
parent 167a92f621
commit 727c87339b
9 changed files with 23 additions and 29 deletions
View File
View File
-103
View File
@@ -1,103 +0,0 @@
# ACDL Tiered Audit Ledger Design (REQ-20)
> **Status:** design authored in Phase 07 (milestone v1.1); the spike
> (Phases 08-10) implements the **v1.0 hash chain + DynamoDB outbox write**
> (D-041); the v1.2 build-out implements S3 Object Lock + JWS + async
> worker + DLQ + daily checkpoints.
The audit stream is the platform's tamper-evident record of every delivery
action. The vision's "Audit truth lives outside the repository" bet [1]
and "Not a mutable audit log" anti-goal [1] are the binding constraints.
Version-control history does not satisfy regulatory evidence; the ledger
is the source of truth.
## Three tiers
- **Cold tier (source of truth):** S3 with **Object Lock in compliance
mode**, **7-year retention** (ARCHITECTURE.md §9). No one — including
root — can delete or overwrite until retention expires. The regulatory
record.
- **Hot tier (query index):** the `acdl-evidence` audit repo (unchanged
from the v1.0 demo). Not part of the chain; a queryable mirror the
evidence UI (`evidence-ui/index.html`) reads. Lightweight attestation
linkage lives in the repo; the regulatory event body lives in S3.
- **Outbox (write path):** DynamoDB, **RPO = 0** (synchronous write before
contract submission ack). Single-region in v1 (`us-east-1`).
## Spike scope (D-041) — what Phases 08-10 implement
- **DynamoDB outbox:** table `acdl-outbox`, `PAY_PER_REQUEST` (D-044),
PK `contractId`, SK `eventType#eventTs`, TTL `expire_at` = now + 365d
(1-year storage per ARCHITECTURE.md §8).
- **`prev_event_hash` chain:** SHA-256 over canonical JSON
(`json.dumps(event, sort_keys=True, separators=(",", ":"))`), lifted
from the v1.0 demo's `evidence_writer.py`. Auto-genesis: first event
has `prev_hash="GENESIS"`.
- **Synchronous write** via boto3 `put_item` (strong-consistent by
default). No separate async worker / DLQ in the spike (RTO = workflow
re-run).
- **Mirror to `acdl-evidence`:** unchanged from v1.0 — the finalize step
commits `audit.json` to the evidence repo (the hot tier).
- **Spike evidence event shape:**
`{seq, ts, stage, event, prev_hash, hash, contractId, environment, stack, score, band}`.
## v1.2 build-out — what Phase 07 designs but the spike defers
- **S3 Object Lock:** bucket `acdl-evidence-lock-<account-id>`, Object
Lock enabled at creation, compliance mode, 7-yr retention
(`RetainUntilDate` = now + 7y). The outbox→S3 path is an async worker
that reads from the outbox and writes to Object Lock.
- **JWS detached signature (RFC 7515):** the event payload is
canonical-JSON-serialized, SHA-256 hashed, signed with a private key;
the signature is stored *detached* alongside the payload. Signing key =
**platform-level KMS key** (not per-contract — a per-contract key would
explode the key-management surface), rotated **quarterly**. The `jws`
field is added to the event shape in v1.2.
- **Async worker + DLQ:** a Lambda (or a Gitea Actions scheduled workflow)
reads the outbox, writes to S3 Object Lock, signs with KMS. DLQ = an
SQS dead-letter queue for failed writes. RTO = DLQ replay.
- **Daily checkpoints (§9):** a daily job reads the last event hash and
writes a "checkpoint" event to the ledger (+ optionally to a public
notarization service). The spike runs in minutes, not days — no
checkpoint in spike.
## JWS vs chain — orthogonality note
The `prev_event_hash` chain gives ordering/tamper-evidence *within* the
log (a deleted event breaks the chain visibly); JWS gives authenticity
*per event* (a forged event is detectable without re-reading the whole
chain). The chain is spike-scope; JWS is v1.2. Together they cover both
integrity properties the vision's "Not a mutable audit log" anti-goal
requires.
## Outbox item shape (full, spike + v1.2)
- PK `contractId` (UUID).
- SK `eventType#eventTs` (e.g. `POLICY_CHECKED#2026-07-21T12:00:00Z`).
- `payload` (the event body — hash-chained in spike, JWS-signed in v1.2).
- `prev_event_hash` (chain link; `GENESIS` for the first event).
- `hash` (this event's SHA-256 over canonical JSON).
- `approver_qa` (Gitea username of the QA approver; empty in dev-only
spike; populated on qa-promotion — D-042).
- `approver_prod` (SRE username; empty in spike).
- `environment`, `stack`, `score`, `band`.
- `expire_at` (TTL = now + 365d).
- **v1.2 only:** `jws` (detached signature), `checkpoint_ref`.
## RPO / RTO table
| Phase | RPO | RTO |
|-------|-----|-----|
| Spike (D-041) | 0 (sync outbox write) | workflow re-run |
| v1.2 | 0 (sync outbox) | async worker DLQ replay |
## Decision trail
- **D-041** — spike scope = hash chain + outbox write; Object Lock + JWS
+ worker + DLQ are v1.2.
- **D-044** — outbox mode `PAY_PER_REQUEST`; PK/SK; TTL `expire_at` =
now + 365d; no separate async worker in spike.
- **D-042** — approver identities (`approver_qa`, `approver_prod`) live
in the outbox; the separation-of-duties check
(`platform/separation_of_duties.py`) reads `approver_qa` and compares
to the prod-dispatch `gitea.actor`.
-175
View File
@@ -1,175 +0,0 @@
"""ACDL Confidence Signal (REQ-19).
The platform's certified answer to "is this safe to proceed?" (vision
tenet: "Safety is Computed, Not Assumed"). Every delivery action produces
a measurable, explainable confidence signal; reliance on operator
instinct is not a substitute.
Inputs (weights sum to 1.0, D-040):
1. policy_results (0.30) — list[PolicyCheckResult] (schemas/policy_check_result.schema.json)
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, ARCHITECTURE.md §8):
critical -> hard override (score = 0, block)
high -> -0.20
medium -> -0.05
low -> -0.01
info -> 0.00
Per-env thresholds (locked, ARCHITECTURE.md §8): dev 0.50, qa 0.75, prod 0.90, dr 0.95.
Output: {score, band, perInput, reasonCodes}.
Halt with explicit reason on missing input (§8).
Spike cold-start (A-6.2): inputs 3 (freshness), 5 (history), 6 (nfrs) are
'present + neutral 0.5' because the spike is the first submission with no
history and no declared NFRs. The gate is *presence*, not *conformance* —
the 'all six inputs present' dev gate (§5) is satisfied by non-null
per-input scores.
"""
from dataclasses import dataclass, asdict
from typing import List, Literal, Optional, Dict, Any
import json
import sys
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,
}
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[str, float]
reasonCodes: List[str]
def _per_input_score(name: str, raw: Any) -> tuple:
"""Return (score in [0,1], reasons list). Unknown/missing -> 0.5 + INPUT_MISSING."""
reasons: List[str] = []
if raw is None:
return 0.5, [f"INPUT_MISSING:{name}"]
if name == "policy":
pcrs = raw if isinstance(raw, list) else []
if not pcrs:
return 0.5, []
scores = []
for pcr in pcrs:
r = pcr.get("result", "skipped")
if r == "pass" or r == "skipped":
scores.append(1.0)
else:
scores.append(0.0)
return sum(scores) / len(scores), []
if name == "validation":
keys = ("schema", "ir_resolved", "tf_validated", "tf_planned")
if not isinstance(raw, dict):
return 0.5, []
trues = sum(1 for k in keys if raw.get(k))
return trues / 4.0, []
if name == "freshness":
if not isinstance(raw, dict):
return 0.5, []
age = float(raw.get("age_days", 0))
mx = float(raw.get("max_age_days", 1)) or 1
s = 1.0 - (age / mx)
return max(0.0, min(1.0, s)), []
if name == "source":
if not isinstance(raw, dict):
return 0.5, []
if raw.get("submitter") and raw.get("commit_sha"):
return 1.0, []
return 0.5, []
if name == "history":
if not isinstance(raw, dict):
return 0.5, []
rollbacks = int(raw.get("prior_rollbacks", 0))
fails = int(raw.get("prior_policy_fails", 0))
s = 1.0 - (rollbacks * 0.2 + fails * 0.1)
return max(0.0, min(1.0, s)), []
if name == "nfrs":
if not isinstance(raw, dict):
return 0.5, []
conf = raw.get("conformance")
if conf is None:
return 0.5, []
return float(conf), []
return 0.5, []
def compute(contract_id: str, environment: str,
inputs: Dict[str, Any]) -> Signal:
"""Orchestrate the 6-input weighted sum + severity penalty + band."""
missing = sorted(set(WEIGHTS.keys()) - set(inputs.keys()))
if missing:
return Signal(0.0, "block", {},
[f"INPUT_MISSING:{m}" for m in missing])
per_input: Dict[str, float] = {}
reasons: List[str] = []
base = 0.0
for name, weight in WEIGHTS.items():
raw = inputs.get(name)
s, r = _per_input_score(name, raw)
per_input[name] = s
reasons.extend(r)
base += s * weight
penalty = 0.0
policy_input = inputs.get("policy")
pcrs = policy_input if isinstance(policy_input, list) else []
for pcr in pcrs:
if not isinstance(pcr, dict):
continue
if pcr.get("result") != "fail":
continue
sev = pcr.get("severity")
p = PENALTY.get(sev, 0.0)
if p is None:
return Signal(0.0, "block", per_input,
reasons + [f"CRITICAL_OVERRIDE:{pcr.get('ruleId','?')}"])
penalty += p
score = max(0.0, min(1.0, base - penalty))
threshold = THRESHOLDS[environment]
if score >= threshold:
band = "pass"
elif score < threshold - 0.10:
band = "block"
else:
band = "warn"
if environment == "dev" and band == "warn":
band = "block"
return Signal(score, band, per_input, reasons)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("usage: confidence_signal.py <inputs.json> <environment>", file=sys.stderr)
sys.exit(2)
env = sys.argv[2]
with open(sys.argv[1], "r", encoding="utf-8") as fh:
inputs = json.load(fh)
sig = compute("cli", env, inputs)
print(json.dumps(asdict(sig), indent=2))
-115
View File
@@ -1,115 +0,0 @@
# ACDL Human-in-the-Loop Matrix + Separation-of-Duties Design (REQ-21)
> **Status:** design authored in Phase 07 (milestone v1.1); v1.2 wires the
> gates. The spike (Phases 08-10) is **dev-only**; HITL is not exercised
> (the spike contract has `environment: dev`).
The vision's "Lower Environments are Autonomous; Higher Environments are
Attested" tenet [1] and the "deliberate human attestation — not as a
rubber stamp" requirement [1] are the binding constraints.
## Gate model (ARCHITECTURE.md §10.1)
**Pre-execution gates.** The contract is held in a "validated but not
applied" state until the human attests. qa, prod, dr are attestation
gates. No partial deployment to roll back on rejection (qa, prod); dr is
a separate deployment against a separate cluster/region. The
canary/deployment-rollback model is explicitly not in scope for v1.
## Gitea-specific gate mechanics (D-042)
Gitea has **no Environments API** and ignores `environment:` blocks
(v1.0 D-013; re-confirmed in RESEARCH TARGET 1). The pre-execution gate
is modeled as a `workflow_dispatch` with approval inputs:
- **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`;
`gitea.actor` is the SRE approver.
- **dr gate:** `workflow_dispatch` with `approve_dr: true`; same.
The approver identity of record = `gitea.actor` of the dispatch run
(D-042). There is no other approval-identity signal in Gitea. The v1.2
real-OIDC path (blocked on go-gitea/gitea#36988) does not change this —
OIDC authorizes the *runner* to AWS, it does not change how the platform
records the *human* approver.
## Reviewer routing (ARCHITECTURE.md §10.2)
Gitea CODEOWNERS routes the right reviewer to the right gate:
- qa → QA team
- prod → SRE team
- dr → SRE team
CODEOWNERS **routes**; it does **not** enforce identity distinctness (that
is the platform-internal outbox check in
`platform/separation_of_duties.py`).
## Full 8-concern attestation matrix (§10.4, lifted verbatim)
| Env | Concern | Evidence artifact | Freshness | Source | Attester |
|---|---|---|---|---|---|
| qa | Functional correctness | Last successful run of contract-declared validation.e2eSuite with pass rate ≥ 99% | Last 24h | Test runner declared in contract | QA |
| qa | Performance baseline | Load test report (k6 / Gatling / Locust) showing p99 latency < declared NFR and throughput > declared minimum | Last 7d | Load test runner declared in contract | QA |
| qa | Security posture | Vulnerability scan (Trivy, Snyk, or contract-declared equivalent) with no criticals/highs, signed by Security on-call | Last 24h | Security scanner + Security team signature | QA |
| qa | Contract NFRs | Platform-generated report: schema valid, NFR assertions (latency, throughput, error rate) within declared bounds | At submission | Platform contract validator | QA |
| prod | Operational readiness | Runbook published, dashboard exists, on-call rotation assigned, alerts configured | At submission, validated against last 30d history | Platform + SRE | SRE |
| prod | Incident response | Sev-1 runbook tabletop or live drill completed | Last 90d | SRE drill record | SRE |
| prod | Capacity / cost | FinOps forecast for next 30d within budget envelope, cost anomaly baseline stored, budget alert configured | Forecast valid for next 30d | FinOps + SRE | SRE |
| prod | Resilience | DR drill, chaos engineering report, backup verified | DR: 180d; chaos: 90d; backup: 30d | SRE + Platform | SRE |
| dr | dr-region deploy with the most recent prod-bound dr drill as canary evidence | dr drill report | Last 180d | SRE | SRE |
## Timeout behavior (§10.5)
| Time | State | Action |
|---|---|---|
| Submission | PENDING_ATTESTATION | Notify responsible team |
| 1 business day | PENDING_ATTESTATION_WARNING | Notify team + platform on-call (elevated path); emit `PENDING_ATTESTATION_TIMEOUT_WARNING` event |
| 2 business days | PENDING_ATTESTATION_AUTO_FREEZE | Auto-freeze; require re-submission; emit `PENDING_ATTESTATION_AUTO_FREEZE` event; new submission linked via `supersedes` |
**Implementation:** a Gitea `on: schedule` workflow (runs hourly) that
scans the DynamoDB outbox for `PENDING_ATTESTATION` events with `ts`
older than 1/2 business days and emits the warn/freeze events. Not
implemented in the spike (dev-only).
## Rejection and rollback (§10.6)
Rejection returns the contract to a `HELD` state with the rejection
reason captured as a `PROMOTION_REJECTED` event. The consumer fixes the
cause and re-submits; the new submission is linked to the rejected one
via `supersedes` (a contract-schema field — `schemas/contract.schema.json`).
The audit chain is **extended, not torn up** (the "Not a mutable audit
log" anti-goal). No partial deployment to roll back at any v1 gate.
## Separation of duties (§10.3) — pointer to the .py
The identity-distinctness check is platform-internal, not GitHub-native,
not Kyverno (in v1). Sequence:
1. On promotion dev → qa, the platform reads the QA approver's identity
from the `workflow_dispatch` run's `gitea.actor` and writes it to the
DynamoDB outbox keyed by `contractId` (attribute `approver_qa`).
2. On promotion qa → prod, the platform reads the stored `approver_qa`
from the outbox and the new SRE approver's `gitea.actor` from the
prod-dispatch run.
3. If `approver_qa == approver_prod`, the platform blocks the prod
promotion, writes a `SEPARATION_OF_DUTIES_VIOLATION` event to the
evidence stream, and routes a halt artifact to the SRE on-call.
4. The check is implemented in `platform/separation_of_duties.py`
(T-7.8). The platform is the only writer to the outbox; the check is
in the same process that has authority to block the promotion.
## Spike scope note
The spike is dev-only (REQ-27 contract has `environment: dev`), so HITL
is not exercised. Phase 07 authors the design; Phase 10's
`verify_phase10.sh` does not assert HITL behavior. v1.2 wires the gates
against this design.
## Decision trail
- **D-042** — approver identity = `gitea.actor` of the `workflow_dispatch`
run; no Environments API in Gitea.
- **D-013** (v1.0) — the `workflow_dispatch` approval-input fallback,
re-used for the real platform's pre-execution gate model.
-42
View File
@@ -1,42 +0,0 @@
"""Check that qaApprover != prodApprover for a contract (ARCHITECTURE.md
§10.3, D-042). Reads `approver_qa` from the DynamoDB outbox for the
contractId, compares to the prod-dispatch `gitea.actor`. Blocks on
equality, emits `SEPARATION_OF_DUTIES_VIOLATION`, routes a halt artifact
to SRE on-call.
Spike scope (A-8.1): the spike is dev-only (REQ-27 contract has
environment: dev); HITL is not exercised. This module is authored to its
full v1.2 shape but the spike calls it with current_prod_approver=None
and a None outbox_client — the check returns (True, 'no QA approver
recorded (dev-only spike)').
"""
from typing import Optional, Tuple
def check(outbox_client, contract_id: str,
current_prod_approver: Optional[str]) -> Tuple[bool, str]:
"""Return (ok, reason). ok=False means block the prod promotion."""
if outbox_client is None:
return (True, "no outbox client (dev-only spike)")
item = outbox_client.get(contract_id)
if item is None:
return (True, "no prior approver (first promotion)")
qa_approver = item.get("approver_qa")
if not qa_approver:
return (True, "no QA approver recorded (dev-only spike)")
if current_prod_approver is None:
return (True, "no prod approver supplied (dev-only spike)")
if qa_approver == current_prod_approver:
return (False,
f"SEPARATION_OF_DUTIES_VIOLATION: "
f"qaApprover==prodApprover=={qa_approver}")
return (True, "distinct")
def route_halt_artifact(contract_id: str, violation_reason: str,
oncall_client) -> None:
"""Route a halt artifact to SRE on-call. Spike: stub that logs. v1.2
wires a real pager."""
print(f"[halt-artifact] contract={contract_id} reason={violation_reason} "
f"oncall={oncall_client}", flush=True)