diff --git a/deploy/README.md b/deploy/README.md index a512cbd..77e4b38 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -5,24 +5,20 @@ deployment. SQLite is intentionally limited to one replica, `ReadWriteOnce` storage, and an `OnDelete` update: never start two writers against a copied database. -**Image: pinned, published, not rolled out.** Both `image:` references already -carry the immutable release digest -`sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d` -(registry tag `0.1.0`, OCI index; linux/amd64 manifest `sha256:2064d537…`), -pinned at `b51d174`. There is no placeholder left to replace. Both references -MUST stay identical and MUST stay digests — a tag here would let the init -container and the server run different code against one database. +**Image: pinned, published, not rolled out.** Both `image:` references carry +`sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49` +(registry tag `0.1.0-hfact-be1a388`, source `be1a388`). Both references MUST stay +identical immutable digests: migration and server share one database. -**The pinned image predates schema v5.** The published artifact recorded in -[`docs/image-scan-2026-09-06.md`](../docs/image-scan-2026-09-06.md) carries -`LATEST_SCHEMA_VERSION = 3`; this repository is now at 5 -(`entries.principal_type` plus `approvals.human_control`, see [storage-operations.md](../docs/storage-operations.md)). -The pinned pair is self-consistent — that image migrates to 3 and serves 3 — so -nothing is broken by leaving it pinned, but a rollout that must enforce declared human controls or carry approver -principal-type evidence requires cutting a new image at step 3 below. The -`migrate` init container then performs the additive upgrade on the existing -volume; `tests/test_deploy_manifest.py` holds this acknowledgement so the drift -cannot go quiet. +The new artifact carries `LATEST_SCHEMA_VERSION = 5`, matching this source: +`entries.principal_type` and explicit `approvals.human_control`. Its release scan +found zero HIGH/CRITICAL vulnerabilities. Disposable on-disk v4-to-v5 migration, +negative service/agent binding, human binding/consumption and restart persistence +passed inside the non-root container with no network and a read-only root. +See [the image evidence](../docs/evidence/2026-09-10-human-control-image.json). +This is artifact evidence; native identity, audit delivery and production restore +remain deployment gates. Never roll the old v3 image over a v5 database; use the +matching verified pre-migration backup for rollback. Gates 1 and 2 below are the outstanding ones; nothing is deployed today. diff --git a/deploy/approval-engine.yaml b/deploy/approval-engine.yaml index 839e976..601785a 100644 --- a/deploy/approval-engine.yaml +++ b/deploy/approval-engine.yaml @@ -42,7 +42,7 @@ spec: seccompProfile: {type: RuntimeDefault} initContainers: - name: migrate - image: forgejo.coulomb.social/coulomb/approval-engine@sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d + image: forgejo.coulomb.social/coulomb/approval-engine@sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49 args: ["migrate", "--db", "/data/approvals.sqlite"] securityContext: allowPrivilegeEscalation: false @@ -53,7 +53,7 @@ spec: - {name: tmp, mountPath: /tmp} containers: - name: approval-engine - image: forgejo.coulomb.social/coulomb/approval-engine@sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d + image: forgejo.coulomb.social/coulomb/approval-engine@sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49 args: - serve - --production diff --git a/docs/evidence/2026-09-10-human-control-image-smoke.py b/docs/evidence/2026-09-10-human-control-image-smoke.py new file mode 100644 index 0000000..bd0c4d5 --- /dev/null +++ b/docs/evidence/2026-09-10-human-control-image-smoke.py @@ -0,0 +1,59 @@ +import hashlib +import importlib.metadata +import json +import os +import pathlib +import tempfile +from datetime import datetime, timedelta, timezone + +import approval_engine.store as store +from approval_engine.errors import Forbidden + +assert os.getuid() == 10001 +assert store.LATEST_SCHEMA_VERSION == 5 +now = datetime.now(timezone.utc) +binding = dict(action='fixture.consume', target={'id': 'fixture'}, actor='service:fixture', + principal='human:fixture', purpose='disposable-image-verification') +validity = {'not_before': (now-timedelta(minutes=1)).isoformat(), + 'expires_at': (now+timedelta(minutes=5)).isoformat()} +with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory)/'approvals.sqlite' + engine = store.Engine(path) + legacy = engine.create(binding, validity) + engine.add_entry(legacy.id, 'human:legacy', principal_type='human') + engine._conn().execute('ALTER TABLE approvals DROP COLUMN human_control') + engine._conn().execute('PRAGMA user_version=4') + engine._conn().commit() + engine.close() + engine = store.Engine(path) + assert engine.storage_status()['schema_version'] == 5 + assert engine.get(legacy.id).human_control is False + assert engine.claim(legacy.id)['valid_now'] is True + controlled = engine.create(binding, validity, human_control=True) + for principal_type in ('service', 'agent', None): + try: + engine.add_entry(controlled.id, 'nonhuman', principal_type=principal_type) + except Forbidden: + pass + else: + raise AssertionError('nonhuman binding admitted') + assert engine.get(controlled.id).entries == [] + engine.add_entry(controlled.id, 'human:fixture', principal_type='human') + assert engine.claim(controlled.id)['binding']['human_control'] is True + assert engine.claim(controlled.id)['valid_now'] is True + engine.consume(controlled.id, controlled.binding_digest) + assert engine.get(controlled.id).status == 'consumed' + engine.close() + reopened = store.Engine(path) + assert reopened.get(controlled.id).human_control is True + assert reopened.get(controlled.id).status == 'consumed' + reopened.close() +print(json.dumps({ + 'status': 'passed', 'uid': os.getuid(), 'schema_version': 5, + 'store_sha256': hashlib.sha256(pathlib.Path(store.__file__).read_bytes()).hexdigest(), + 'checks': ['nonroot', 'v4-to-v5-persistent-migration', 'legacy-declaration-false', + 'service-agent-unknown-refusal', 'human-bind-claim-consume', 'restart-persistence'], + 'native_identity_or_credential_calls': 0, 'network': 'none', + 'packages': {name: importlib.metadata.version(name) for name in + ('approval-engine', 'PyJWT', 'cryptography', 'waitress')}, +}, indent=2)) diff --git a/docs/evidence/2026-09-10-human-control-image.json b/docs/evidence/2026-09-10-human-control-image.json new file mode 100644 index 0000000..cbeeebd --- /dev/null +++ b/docs/evidence/2026-09-10-human-control-image.json @@ -0,0 +1,69 @@ +{ + "status": "published-not-deployed", + "source_commit": "be1a388a848cf59e9ff3a5420f88a70ca0a52e7e", + "image": { + "repository": "forgejo.coulomb.social/coulomb/approval-engine", + "tag": "0.1.0-hfact-be1a388", + "digest": "sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49", + "linux_amd64_manifest": "sha256:a9fdac46b7a86f7d50389d551731b3dea321f47e90be66bfb8c3e498a8a544c2", + "schema_version": 5 + }, + "scan": { + "scanner_image": "sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969", + "gate": "CRITICAL,HIGH; exit-code 1", + "exit_code": 0, + "high": 0, + "critical": 0, + "os": "Alpine 3.24.1", + "warning": "Scanner does not list Alpine 3.24 in its EOL list; vulnerability detection ran.", + "lower_severities": "not selected by the existing release gate", + "observed_at": "2026-09-10T17:32:22Z" + }, + "container_verification": { + "status": "passed", + "uid": 10001, + "schema_version": 5, + "store_sha256": "eb33ce55f1475e19ab25d0727bc244ffc7375ff9ae5f441d7d9a36fb293d7446", + "checks": [ + "nonroot", + "v4-to-v5-persistent-migration", + "legacy-declaration-false", + "service-agent-unknown-refusal", + "human-bind-claim-consume", + "restart-persistence" + ], + "native_identity_or_credential_calls": 0, + "network": "none", + "packages": { + "approval-engine": "0.1.0", + "PyJWT": "2.13.0", + "cryptography": "50.0.1", + "waitress": "3.0.2" + } + }, + "source_suite": { + "passed": 152, + "new_regressions": 22 + }, + "production_deployed": false, + "factory_attempts": 0, + "paid_model_calls": 0, + "remaining_owner_records": [ + "APPROVAL-WP-0002-T01/T03/T05", + "SECRETS-WP-0009-T03", + "INFD-WP-0001-T07/T08", + "AUDIT-WP-0009-T04/T06/T09" + ], + "log_sha256": { + "image-build.txt": "ec1b54fedaccf618f9fe31df5c142d6db4d9612effd900c05c2134eea97a680b", + "image-scan.txt": "e0024173a72bc9d3862ed476aefcf2224a4bd54612c3c1c6e5bb20777aa2d5d1", + "image-push.txt": "c89bb84760614e782eab64e33c72a578f69615adf5fe8ba35778c733975f0551", + "registry-inspect.txt": "577d321cae9c55ed7a7814b33894ecacaf066e4a7b13cb54c488b72938bae1fb", + "release-tests.txt": "48ac9c02d5e186c834889c99c651827dcc7605e674811ba7c6522c9890d7371f", + "deploy-dry-run.txt": "05e28400d629b0aaefad24c7f12d894ca77eb538427eb1a12c7e0e8bce5d8f32" + }, + "registry_manifest_verified": true, + "deployment_validation": "kubectl apply --dry-run=client: passed (6 resources); no apply", + "verification_script": "docs/evidence/2026-09-10-human-control-image-smoke.py", + "verification_command": "docker run --rm -i --network none --read-only --tmpfs /tmp:rw,nosuid,nodev --cap-drop ALL --security-opt no-new-privileges --entrypoint python forgejo.coulomb.social/coulomb/approval-engine@sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49 - < docs/evidence/2026-09-10-human-control-image-smoke.py" +} diff --git a/tests/test_deploy_manifest.py b/tests/test_deploy_manifest.py index a8c7982..9499080 100644 --- a/tests/test_deploy_manifest.py +++ b/tests/test_deploy_manifest.py @@ -8,6 +8,7 @@ kind of property a test can hold and prose cannot. from __future__ import annotations +import json import re from pathlib import Path @@ -15,7 +16,7 @@ from approval_engine.store import LATEST_SCHEMA_VERSION ROOT = Path(__file__).resolve().parents[1] MANIFEST = ROOT / "deploy" / "approval-engine.yaml" -RELEASE_RECORD = ROOT / "docs" / "image-scan-2026-09-06.md" +RELEASE_RECORD = ROOT / "docs" / "evidence" / "2026-09-10-human-control-image.json" DEPLOY_README = ROOT / "deploy" / "README.md" IMAGE_LINE = re.compile(r"^\s*image:\s*(\S+)\s*$", re.MULTILINE) @@ -78,28 +79,23 @@ def test_pinned_image_schema_drift_is_acknowledged(): The checks above hold that migrate and serve run the *same* code. They cannot see that both run *old* code: a v3 image migrating to 3 and serving - 3 is perfectly self-consistent while this repository has moved to 4. That + 3 is perfectly self-consistent while this repository has moved to 5. That reads as healthy and is the more dangerous shape, because the failure is an assumption ("the deployment records principal_type") rather than an error. - So when the manifest still pins the artifact the release record describes, - the record's schema version must either match this repository or the - runbook must say plainly that the pin is behind. Prose alone loses that; + The release record must describe the exact pinned artifact and its verified + schema. That version must either match this repository or the runbook must + say plainly that the pin is behind. Prose alone loses that; the test makes stating it the cheaper option. """ refs = image_refs() assert refs, "no image references found" pinned = refs[0].split("@", 1)[-1] - record = RELEASE_RECORD.read_text(encoding="utf-8") - if pinned not in record: - return # a newer artifact is pinned; this record no longer describes it - - versions = { - int(v) for v in re.findall(r"LATEST_SCHEMA_VERSION\s*=\s*(\d+)", record) - } - assert len(versions) == 1, f"release record states {versions or 'no'} schema versions" - recorded = versions.pop() + record = json.loads(RELEASE_RECORD.read_text(encoding="utf-8")) + assert pinned == record["image"]["digest"], "release evidence must describe the pinned image" + recorded = record["image"]["schema_version"] + assert record["container_verification"]["schema_version"] == recorded if recorded == LATEST_SCHEMA_VERSION: return diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md index fee4da9..ca51615 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -458,6 +458,25 @@ the v4 section (additive `ALTER TABLE`, legacy entries stay `NULL` and are never back-filled to `human`, downgrade unsupported). T03 stays `wait`: gates 1 and 2 are unchanged and nothing is deployed. 125 tests pass. +### Schema-v5 release candidate — 2026-09-10 + +The earlier schema drift is resolved in the published candidate, source +`be1a388a848cf59e9ff3a5420f88a70ca0a52e7e`, registry tag +`0.1.0-hfact-be1a388`, immutable digest +`sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49`. +Both migration and serving references now pin it. The sanctioned HIGH/CRITICAL +scan passes with zero findings. Disposable container checks prove schema v5, +v4 migration without inferred human declaration, nonhuman bind refusal, +human bind/claim/consume and restart persistence; packaged store bytes match +committed source. These checks use synthetic local identities and no network. +See `docs/evidence/2026-09-10-human-control-image.json` for the exact evidence. + +T03 remains `wait`: native KeyCape requester/approver registration and audit +sender custody still precede rollout, live persistence/outbox/restore proof. +The factory requester and PEP must explicitly declare/require human_control in +T05 with SECRETS-WP-0009-T03 and INFD-WP-0001-T08. No production database was +migrated, no workload deployed and no factory/model request admitted. + ## Wire outbox delivery and reconciliation ```task