railiance-infra/workplans/RAIL-HO-WP-0015-s1-execution-attribution-receipt-owner-answer.md

133 lines
7.9 KiB
Markdown
Raw Normal View History

---
id: RAIL-HO-WP-0015
type: workplan
title: "S1 playbook runner: owner answer for the execution-attribution receipt"
domain: financials
repo: railiance-infra
status: active
flavor: decision
owner: railiance-infra
topic_slug: railiance
created: "2026-10-28"
updated: "2026-10-28"
related_workplans:
- NK-WP-0040
- RPF-WP-0067
- AUDIT-WP-0014
- RAIL-HO-WP-0013
---
Owner record for the net-kingdom request (State Hub message
`848ef70a-992e-4834-8d8a-a5ba3c67f96f`, NK-WP-0040-T02) to name the component
that emits the "Execution Attribution Receipt (proposed)" of
`net-kingdom/canon/standards/playbook-capability-contract_v0.2.md` (lines
292-337) for the reference declaration
`capabilities/playbooks/railiance-infra.bootstrap-host.yaml`. Answered from
source only. Emission is **not** implemented here.
## T01 — Answer the four owner questions from source
```task
id: RAIL-HO-WP-0015-T01
status: done
priority: high
```
**(1) What runs S1 playbooks today.** There is no runner component. S1
playbooks are run by a human operator calling `ansible-playbook` directly
through Make targets:
- The declaration names `invocation: make converge` for
`ansible/playbooks/bootstrap.yaml`
(`capabilities/playbooks/railiance-infra.bootstrap-host.yaml:16-19`).
- `make converge` is `cd ansible && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG)`
with `PLAY := ansible/playbooks/bootstrap.yaml` (`Makefile:284`, `Makefile:361-362`);
`converge-host` / `converge-tags` / `ansible-bootstrap` are variants
(`Makefile:193-194`, `Makefile:364-370`). `SSH_USER` is optional; the
remote user otherwise comes from inventory (`Makefile:285-288`).
- The documented live path is the operator on the host:
`ssh tegwick@92.205.62.239 '... ansible-playbook playbooks/bootstrap.yaml -c local --become -l Railiance01'`
(`Makefile:234`, printed by `make deploy-stack`).
- `ansible/ansible.cfg:7` uses `stdout_callback = default`; there is no
callback plugin, wrapper or service that records a run.
Started by: the founder/operator (`tegwick`) from a shell, on the
workstation or on Railiance01. Not a cluster workload, CI job or agent
service. The only existing receipt-producing wrapper is
`scripts/s1_handoff.py` (`make s1-handoff`, `Makefile:84-85`), and it wraps
the **read-only** `verify.yaml`, not the converge playbook
(`scripts/s1_handoff.py:133-139`). It writes a local JSON file under
`reports/` (`scripts/s1_handoff.py:153-159`) and sends nothing to audit-core.
Owner decision: the emitter will be a railiance-infra **converge wrapper**
modelled on `scripts/s1_handoff.py` (Python, invoked by `make converge`
in place of the bare `ansible-playbook` call). It does not exist yet.
**(2) Emission at every terminal outcome keyed by run_id.** Not today.
`make converge` emits nothing. `s1_handoff.py` emits only on its normal
path: it returns 2 without a receipt on a dirty checkout
(`scripts/s1_handoff.py:119-123`) or a missing `ansible-playbook`
(`:129-131`), and an inventory/spec validation exception exits without a
receipt (`:111-117`). Owner commitment for the future wrapper: one
`run_id` (UUID4, as `s1_handoff.py:63`) generated before anything else, and
a receipt emitted from a `finally` path for every terminal outcome:
`succeeded`, `failed` (non-zero ansible exit), `refused` (pre-flight
refusal, e.g. dirty tree) and `interrupted` (signal/exception). A receipt
that cannot be delivered is kept locally under `reports/` and retried with
the same `run_id` as Idempotency-Key. This is a commitment, gated on the
receipt schema (NK-WP-0040-T02) and the sender credential below.
**(3) audit-core sender identity.** Proposed sender name:
`railiance-infra-s1-runner` (envelope `source` = that name, per the pattern
in `railiance-platform/workplans/RPF-WP-0067-*.md:49-67`). No sender exists
(RPF-WP-0067:66-67); the credential is a CCR to railiance-platform, not done
here. **Open, needs founder/railiance-platform:** the documented delivery is
a per-sender bearer projected by ESO into a Kubernetes Secret. The S1 runner
runs in an operator shell, not a pod, so that projection does not reach it.
How an operator-run process gets that bearer (for example a short-lived
fetch from OpenBao by the operator, or sending through an in-cluster relay)
is not decided in source.
**(4) Per-field fillability today** (for a wrapper built from current
source; "no" fields must be recorded as `unknown`, or omitted where the
contract says so, never inferred):
| Field | Truthful today? | Basis / gap |
| --- | --- | --- |
| `initiator.iss` / `initiator.sub` | No | Operator starts the run from a shell; no IAM Profile token is presented or checked anywhere on the path (`Makefile:361-362`, `:234`). |
| `delegation` | No | The runner cannot tell whether an agent invoked `make` for the operator. The contract allows "absent" only when there was no delegation (`playbook-capability-contract_v0.2.md:315`), which the runner cannot assert. Needs an `unknown` value in the schema (NK). |
| `executing_workload` | No | No workload binding: the run is a shell process, not a Tenancy Posture workload. |
| `runtime_principal` | Partly | The local OS user and the remote SSH user / `become` root can be read at run time. These are OS accounts, not IAM principals. A warden SSH cert principal, if used, is not visible to the wrapper. |
| `tenant` | No | `inventory/servers.yaml` has no tenant field. S1 acts for the platform; the value to record needs an NK convention. |
| `environment` | No | `inventory/servers.yaml` has no environment field (hosts carry `baseline_profile` only, `:9`, `:15`). |
| `run_id` | Yes | UUID4 per run (pattern `scripts/s1_handoff.py:63`). |
| `source_commit` | Yes | `git rev-parse HEAD` (`Makefile:9`, `s1_handoff.py:119`), but only truthful with the clean-tree refusal (`s1_handoff.py:120-123`); `make converge` has no such check today. |
| `artifact_digest` | No | No built artifact. A digest of the playbook + roles tree and `inventory_sha256` (`s1_handoff.py:68`) is possible, but the Galaxy collections are unpinned (`ansible/requirements.yaml:2-4`), so the executed code is not fully pinned. What the digest covers for an Ansible tree needs an NK definition. |
| `target` | Yes | Inventory host names from `--limit` / inventory plus inventory SHA-256 (`s1_handoff.py:38-46`, `:68-70`). |
| `action` | Yes | Playbook path + tags/limit, e.g. `ansible/playbooks/bootstrap.yaml` / `converge`. |
| `decision_refs` | No | `make converge` has no approval gate. The gated targets use approval tokens (`APPROVE_VERIFY_REFRESH`, `Makefile:352-356`; tf-apply "exact approval", `:101`), not decision record ids. |
| `time_interval.start` / `.end` | Yes | Controller UTC wall clock around the `ansible-playbook` call (`s1_handoff.py:34-35`, `:125`). |
| `time_interval.clock_source` | Partly | Can name "controller system clock"; its sync state is not verified by the runner. |
| `time_interval.bound` | No (omit) | No bound established: `railiance_clock_enabled: false` by default (`ansible/roles/railiance_clock/defaults/main.yaml:3`) and RAIL-HO-WP-0013 is `blocked`. The `max_error_ns` default (`:16`) is a policy target, not a measured bound. |
Side observation (not investigated further): `bootstrap.yaml:14` names a
`swapfile` role that is neither under `ansible/roles/` nor in
`ansible/requirements.yaml`, so `make converge` as declared may not run
cleanly. Should be checked before any wrapper work.
## T02 — Build the converge wrapper and emit receipts
```task
id: RAIL-HO-WP-0015-T02
status: wait
priority: medium
```
Waits on: the receipt schema and validator in net-kingdom `canon/schemas/`
(NK-WP-0040-T02), including an `unknown` value for `delegation` and
definitions for `tenant`, `environment` and `artifact_digest` for S1; the
`railiance-infra-s1-runner` sender in the audit-core registry plus a decided
bearer delivery path for an operator-run process (railiance-platform CCR,
founder decision). Wrapper scope: replace the bare `ansible-playbook` call in
`make converge*` with the wrapper. Do not start before these are settled.