audit-core/docs/bounded-executor-receipts.md

351 lines
19 KiB
Markdown
Raw Normal View History

# Bounded-executor execution-attribution receipts — owner answer
Answers NetKingdom NK-WP-0056-T02 (owner request
`net-kingdom/docs/proposals/2026-10-31-nk56-t02-owner-requests.md`, section
"audit-core"; hub message `755cbeed-ecbd-46d9-883e-958fbd88378b`). Reviewed
2026-10-31 against `net-kingdom/canon/standards/bounded-execution-admission_v0.1.md`
section 6 and `playbook-capability-contract_v0.2.md` "Execution Attribution
Receipt (proposed)". Work: AUDIT-WP-0018. Canonical receipt validation remains
AUDIT-WP-0014-T02.
This is an owner proposal. It activates nothing: registration, ingress and the
sender bearer require the owner deliveries and the approved live change listed
at the end.
## Answer 1 — will audit-core hold the receipts?
Yes. Audit-core accepts the evidence-holder role for the bounded executor's
receipts, as it already did for NK-WP-0040 (`docs/cross-repo-evidence-admission.md`,
"Execution-attribution receipts"). What changes is the emitter: no longer a
Railiance component but the NetKingdom-written executor, deployed by the new
`rapp-*` package, running as `service:nk-bind-executor` in namespace `sso`.
Holding means what it means for every other sender (`docs/event-envelope.md`):
audit-core authenticates the sender, checks source and tenant against the
registered identity, stores the event verbatim, and chains it. It records that
the executor *said* the run happened this way. It does not decide whether the
`decision_refs` authorized the run, and it does not authenticate the initiator
chain. That check belongs to the independent verifier in BEA section 6
("Verify"), which reads the receipt from audit-core and compares it with the
carrier record.
Until AUDIT-WP-0014-T02 ships canonical schema validation, an accepted receipt
is generic JSON custody, not a schema-validated receipt. Audit-core will not
describe it otherwise.
## Answer 2 — one event, or separate events by `run_id`?
Both, in a fixed shape: **one receipt event per run carries all per-step detail**,
plus **one `started` event** emitted when the executor has consumed the approval and
before it takes its first step. Do not split steps into separate events.
Why the steps stay inside the receipt:
- The envelope stores `data` verbatim as one object (`docs/event-envelope.md`).
Per-step outcomes, check results, revert yes/no and budget used are the
sender's claims. They belong in `data`, where they read as claims. The stored
`outcome` is always `recorded` and `actor` is always `null`
(`docs/event-envelope.md`, "What the receiver derives").
- One receipt is the retained evidence unit. BEA section 6 says the receipt is
"kept by hash". If steps were split into separate events, nobody could tell
whether a step event was missing without a declared count. The receipt would
then become a reconstruction, not a record.
- The validator then sees one document, so `decision_refs`, budget and step
list are checked together.
Why a separate `started` event is still needed: a receipt is emitted at the end
of a run. If the executor crashes mid-run after it has consumed its single
approval, it emits nothing. Without a second event, a run that partly executed
leaves no record in custody. With a `started` event, a `started` that has no
matching receipt after the admission window is detectable. Both events share the
`run_id`.
## Event mapping (proposal)
Both events use the existing eight-field envelope. No envelope change is needed.
| Envelope field | `started` event | Receipt event |
| --- | --- | --- |
| `type` | `execution.run.started` | `execution.attribution.receipt` |
| `id` / `Idempotency-Key` | `run:<run_id>:started` | `run:<run_id>:receipt` |
| `source` | `net-kingdom.bounded-executor` (exact; emitter confirms) | same |
| `subject` | `admission:<spec.id>` | same |
| `tenant` | the admission's tenant, exact string (owner-bound, never inferred from namespace) | same; must equal `data.tenant` |
| `correlation_id` | `<run_id>` | `<run_id>` |
| `occurred_at` | consume time, offset-aware | `time_interval.end`, offset-aware |
| `data` | `run_id`, `admission_id`, `admission_digest`, `approval_ref`, `executing_workload`, `runtime_principal`, `plan_digest`, `steps_planned` | the canonical receipt (below) |
Each `id` is derived deterministically from `run_id`. A retry is therefore a
`200` duplicate. A second, different body under the same id is a `409` conflict
(`audit_core/ingestion.py` response table), which exposes reuse of a run id.
Proposed receipt `data`. The schema belongs to NetKingdom (NK-WP-0040-T02);
this lists what audit-core needs to see in it:
```json
{
"receipt_version": "<canonical schema version>",
"run_id": "…",
"initiator": {"iss": "…", "sub": "admin"},
"executing_workload": "service:nk-bind-executor",
"runtime_principal": "system:serviceaccount:sso:<sa>",
"tenant": "…",
"environment": "…",
"source_commit": "<plan.source.commit>",
"artifact_digest": "<plan_digest>",
"target": "<admission-level target>",
"action": "bounded-execution",
"decision_refs": [
{"kind": "admission", "id": "<spec.id>"},
{"kind": "admission_digest", "id": "sha256:…"},
{"kind": "approval", "id": "<carrier approval reference>"}
],
"time_interval": {"start": "…+00:00", "end": "…+00:00", "clock_source": "…"},
"steps": [
{"index": 1, "operation": "…", "target": "…", "action": "…",
"outcome": "succeeded|failed|skipped",
"checks": [{"id": "…", "result": "pass|fail|unknown"}]}
],
"reverted": false,
"budget": {"limit": {"…": 0}, "used": {"…": 0}},
"outcome": "succeeded|failed|reverted"
}
```
The points that fit the existing model and need NetKingdom to decide:
1. **`target`/`action` are singular in the canonical field list but per-step in
BEA section 6.** Proposal: the receipt-level `target` is the admission's
target and the receipt-level `action` is `bounded-execution`. Each step's
target and action go in `steps[]`. Audit-core needs one answer in the schema.
It will not pick one.
2. **`decision_refs` must be typed.** The admission id, the admission digest and
the approval reference are three different things. An untyped list cannot
be checked against the carrier.
3. **`delegation` stays absent** when there is none. `time_interval.bound` stays
absent when no bound is established. Unknown attribution stays the explicit
`unknown`. This is unchanged from `docs/cross-repo-evidence-admission.md`.
4. **Check results carry an id and a result only.** They carry no response
bodies, tokens, password values or LLDAP/OpenBao payloads.
## Registration (proposal)
| Registry field | Proposed value | Reason |
| --- | --- | --- |
| `name` | `nk-bounded-executor` | one identity per executor deployment |
| `sources` | `["net-kingdom.bounded-executor"]` | exact, no wildcard |
| `tenants` | the exact admission tenant(s) | not `*`; the executor acts for named tenants only |
| `may_write` / `may_read` | `true` / `false` | the verifier reads with its own read identity |
| `secret_policy` | `reject` | a receipt is kept by hash. If audit-core redacted it silently, the stored body would no longer match the hash the executor computed, so a secret-shaped field must refuse the event (`400 secret_shaped_field`). The executor treats that as a defect |
| `evidence_kind` | `load-bearing`, if the `started` + receipt pair is adopted. Otherwise `attributive` with an explicit `completeness_trade` | the pair is what makes a missing receipt detectable |
Ingress: a new NetworkPolicy in `deploy/networkpolicies.yaml` admitting
namespace `sso` and the executor's exact pod labels, following
`audit-core-tenant-engine-ingress`. Do not admit the whole `sso` namespace,
because Authelia, LLDAP and KeyCape run there too. The bearer is
ESO-projected by railiance-platform, like the other in-cluster senders. No token
value appears in this repository.
## What audit-core needs back
- **NetKingdom (NK-WP-0040-T02 / NK-WP-0056):**
- the versioned receipt schema, validator, path and digest, with positive and
negative fixtures, covering `steps`, `reverted`, `budget` and the typed
`decision_refs`;
- an answer to point 1;
- the exact `source` and event types;
- the tenant binding;
- a decision on the `started` event and the emission guarantee.
- **The rapp-\* package owner:** the exact pod labels and ServiceAccount, and
the image revision that emits.
- **railiance-platform:** the ESO projection of the sender bearer
(catalog/route via `warden route`), and the attended seed of the registry
entry.
- **Founder:** the live registry entry and NetworkPolicy rollout are an approved
live change. Audit-core prepares them; the founder runs them.
## Status 2026-10-31 — T02 prepared after NetKingdom's answers
NetKingdom answered on hub messages `ed03ba30` and `deea5bf8`. The answers are
based on executor.py at net-kingdom a020758 and rapp-bounded-executor 9826a24.
- Source `net-kingdom.bounded-executor`. Event types `execution.run.started` and
`execution.attribution.receipt`. Tenant `platform`, which is the exact
string. Note that it is not `tenant:platform`, which approval-engine and
informed-decision use. The `started` event is adopted, so the stream is
`load-bearing`.
- **Registry entry (prepared, not seeded):** `nk-bounded-executor` in
`deploy/senders-scope.json` and `deploy/senders-scope.yaml`. The entry admits
nothing until the Secret carries a token for that name. The overlay may now
tighten `secret_policy` from `redact` to `reject`, but it may never relax it
(`audit_core/senders.py`). Even if the Secret entry is minted with the
default, the result is therefore `reject`.
- **Ingress (prepared, not applied):** `audit-core-nk-bounded-executor-ingress`
in `deploy/networkpolicies.yaml` admits only namespace `sso` combined (AND)
with pod label `app.kubernetes.io/name=bounded-executor`, on TCP 8080.
- **Tests:** `tests/test_bounded_executor_sender.py` runs on the executor's
envelope shape. It checks: 202 accept; 200 duplicate on retry; 409 on a
reused run id with a different body; 400 `source_not_allowed` and
`tenant_not_allowed`; 400 `secret_shaped_field` for a secret-shaped step
check.
- **Defect found in the emitter:** `bounded-executor/clients.py`
`AuditCore.emit` accepts only 200 and 201. Audit-core answers a first accept
with **202**, so the executor would treat a stored `started` event as a
failure and stop with `audit_unavailable`. NetKingdom has to accept 202
(accepted) and 200 (duplicate). It should treat 409 as a run-id-reuse
defect and must not retry it.
- **T03 window bound:** a `started` event that has no receipt with the same
`correlation_id` becomes a finding after the admission's `window.not_after`.
Pilot windows are 2 h.
Remaining work:
- **Founder-run:** mint and seed the Secret entry, then roll out the ConfigMap
and the NetworkPolicy.
- **NetKingdom:** the versioned receipt schema with fixtures (NK-WP-0040-T02)
and the 202 fix.
- **railiance-platform:** custody of the sender bearer (ESO projection to
`sso`).
## Status 2026-11-03 — T03 detection implemented
A `started` event that has no receipt is reported on `GET /v1/stream-findings`
as `opening_without_closing` (see `docs/stream-completeness.md`, "Opening
events that never close"). The pair is declared on `nk-bounded-executor` in
`deploy/senders-scope.{json,yaml}` with a 7200 s bound, which matches the pilot
windows. The finding appears 3 h after acceptance at the latest, because the
heartbeat grace allowance is added.
The executor's `started` body (net-kingdom `bounded-executor/executor.py`)
does not yet carry the admission window. If NetKingdom adds
`data.window.not_after`, each run is held to its own window wherever that is
earlier than the declared bound. Without it, admissions longer than 2 h rely
on the declared ceiling.
The executor's emitter now accepts 202 (accepted) and 200 (duplicate)
(`clients.AuditCore.emit`), which closes the defect recorded on 2026-10-31.
Released 2026-11-03: receiver `sha256:5f3d01c8…` (built from 2b8c909) and
the scope ConfigMap carrying `closing_classes` are live on railiance01. The
detection is inert until the `nk-bounded-executor` token is seeded and the
executor emits.
## Status 2026-11-04 — what is still needed live (NK-WP-0056-T15/T16)
Checked read-only on 2026-11-04 with the default (limited) identity:
- **Ingress: not live.** `audit-core-nk-bounded-executor-ingress` is the only
object in `deploy/networkpolicies.yaml` that is absent on railiance01. The
nine other policies in that file match the live specs exactly. The
executor's own egress (`sso/bounded-executor`, to `audit-core` pods on 8080) is
already live.
- **Scope ConfigMap:** the limited identity cannot read ConfigMaps in
`audit-core`. The release on 2026-11-03 is recorded in 06d0029. The seed
script below refuses unless the live entry equals `deploy/senders-scope.json`.
- **Bearer: not minted.** The registry is OpenBao
`platform/workloads/audit-core/senders` (field `senders.json`). ExternalSecret
`audit-core/audit-core-senders` projects it into the receiver. The same
pattern served tenant-engine and the CCR-2026-0021/0022 senders.
How the token reaches both sides. One founder-run process,
`scripts/seed-nk-bounded-executor-sender.py`, does the following:
1. It mints one bearer.
2. It writes the bearer with cas 0 to
`platform/workloads/net-kingdom/bounded-executor/audit-sender` (`AUDIT_TOKEN`,
`CUSTODY_REQUEST=CCR-2026-0063`).
3. It appends the `nk-bounded-executor` entry to the registry. The entry is the
declared scope plus the token, written with cas on the version read. Every
other sender stays byte-equal.
4. It force-syncs `audit-core-senders` and compares the token by SHA-256 only.
5. It restarts the receiver.
6. It proves the bearer without writing an event. Inside the receiver pod, with
the bearer on stdin:
- a read gets 403 (write-only);
- reconciliation for its own source and tenant `platform` gets 200;
- tenant `tenant:platform` gets 403;
- another source gets 403.
An unknown bearer gets 401.
7. It force-syncs `sso/bounded-executor-tokens` and compares key `audit-core`
by hash, but only if that ExternalSecret exists. That is the Secret the
rapp run Job already mounts.
The script is silent. The receipt holds versions, booleans and a 12-hex
fingerprint. A rerun reuses its own custody and writes nothing new. It refuses
foreign or drifted custody, a sender registered without custody, a token
collision and a concurrent registry write. Tests:
`tests/test_seed_nk_bounded_executor_sender.py`.
The script refuses before any write unless all of the following hold:
- CCR-2026-0063 is `approved` or later, with all three owner reviews. Its
reader shape must be audience `openbao`, explicit max TTL and token self-lifecycle.
Its delivery must be `sso/bounded-executor-tokens` key `audit-core`, and it must
name this script as its custody procedure;
- the cluster is railiance01 (kube-system UID);
- the receiver runs the pinned image;
- the live scope ConfigMap matches;
- the ingress policy exists;
- the coding-agent boundary already denies the custody data and metadata paths.
CCR-2026-0063 (railiance-platform f3b0c4f) names this script as its single
seeder. The script does not create the reader policy, the role or the boundary.
railiance-platform owns those: its credential-change applier creates the
policy and role, and `scripts/nk_bind_executor_custody.py fence` creates the
boundary. Nor does it create ClusterSecretStore `openbao-nk-bind-executor-audit`
or ExternalSecret `sso/bounded-executor-tokens`, which are railiance-platform's
`manifests/nk-bind-executor-eso.yaml`.
## Founder runbook — nk-bounded-executor sender
Run these in order. Steps 1 and 2 can run now. Step 3 depends on CCR-2026-0063:
railiance-platform must have it approved by platform-owner, audit-core-owner and
net-kingdom-owner, must have applied its reader policy and role, and must have
run `nk_bind_executor_custody.py fence` for the coding-agent boundary entries.
**1. Ingress (NK-WP-0056-T16).** This applies exactly one object. Run the server
dry-run first, then the apply:
```bash
cd /home/worsch/audit-core && python3 -c 'import sys,yaml; d=[x for x in yaml.safe_load_all(open("deploy/networkpolicies.yaml")) if x and x["metadata"]["name"]=="audit-core-nk-bounded-executor-ingress"]; assert len(d)==1; yaml.safe_dump(d[0],sys.stdout)' | KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl apply --dry-run=server -f -
cd /home/worsch/audit-core && python3 -c 'import sys,yaml; d=[x for x in yaml.safe_load_all(open("deploy/networkpolicies.yaml")) if x and x["metadata"]["name"]=="audit-core-nk-bounded-executor-ingress"]; assert len(d)==1; yaml.safe_dump(d[0],sys.stdout)' | KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl apply -f -
```
**2. Read-only preflight** (prints no secrets):
```bash
KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl -n audit-core get configmap audit-core-senders-scope -o jsonpath='{.data.senders-scope\.json}' | python3 -c 'import sys,json; print([e for e in json.load(sys.stdin) if e["name"]=="nk-bounded-executor"])'
KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl -n audit-core get deploy audit-core -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
bridge status
```
The image must end in
`sha256:5f3d01c8a1f90411a1429451b46c66a9ac9262fc7fce68790c16e22eeb7871a6`.
The `openbao-ui-railiance01` tunnel must be up.
**3. Seed and reload (NK-WP-0056-T15). Needs CCR-2026-0063 approved and applied.**
This is one silent command. Read the result from the receipt, not from the
terminal:
```bash
BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- /home/worsch/audit-core/scripts/attended-seed-nk-bounded-executor-sender.sh /home/worsch/audit-core/docs/evidence/nk-bounded-executor-sender-seed-r1.json
```
The receipt is created exclusively. If you rerun, use `-r2.json`. The receiver
restart is part of this step. No separate rollout is needed.
**4. Verify** (read-only):
```bash
python3 -c 'import json; r=json.load(open("/home/worsch/audit-core/docs/evidence/nk-bounded-executor-sender-seed-r1.json")); print(r["status"], r.get("error"), r.get("receiver_probe"), r.get("registry_secret_hash_match"), r.get("sso_secret_hash_match"), r["credential_values_emitted"])'
KUBECONFIG=/home/worsch/.kube/config-railiance01 kubectl -n audit-core get networkpolicy audit-core-nk-bounded-executor-ingress
```
The expected status is `seeded_pending_sso_delivery`, or `seeded_and_delivered`
once railiance-platform's ExternalSecret `sso/bounded-executor-tokens` exists. The expected probe is
`{"read": 403, "own_tenant": 200, "other_tenant": 403, "other_source": 403}`.
If railiance-platform applies `manifests/nk-bind-executor-eso.yaml` after the
seed, rerun step 3 with
`-r2.json`. It writes no custody or registry and confirms the `sso` copy by
hash.