NK-WP-0058-T08 worker protocol and excerpt tool; T06 window watch (row O5), first use of the protocol

T08: .claude/rules/worker-protocol.md (imported by CLAUDE.md, pointed to from AGENTS.md) and
tools/contract-tests/excerpt.py: name the rows, hand every worker one excerpt, one owner per step, check
with the excerpt's tests. A test requires every table row to have details and a contract test (found O4).
T06: bounded-executor/window_watch.py flags or closes the write role when no run Job is active or recent;
stub tests; in the image. Built from the excerpt for O5, E1, C1, E8, E9, R2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 943911@bnt-lap001
Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0
This commit is contained in:
tegwick 2026-11-06 01:36:09 +01:00
parent 558bfce848
commit c63aa398d6
9 changed files with 398 additions and 3 deletions

View file

@ -0,0 +1,18 @@
## Cross-repo worker protocol (NK-WP-0058-T08)
**Applies when** a change touches an interface on the bounded-execution path, or work is split across repositories
or worker agents. On 2026-11-04, workers in two repos built competing seeders and used different Secret names.
1. **Name the rows.** List the rows of `docs/bounded-execution-contract-table.md` the change touches. A new interface
gets its row first: owner and source of truth, consumer, the exact contract, and the test that will cover it.
2. **Hand out one excerpt.** Run `python3 tools/contract-tests/excerpt.py <rows>` and give every worker that
excerpt verbatim. Names, paths, roles, payloads and exit codes come from it. A worker never invents a second
name or a parallel script for a step that already has an owning script.
3. **One owner per step.** A worker changes only its own repository's part. Another repository's part is a request
to that owner, unless the founder has instructed otherwise; record the instruction.
4. **Check against the excerpt.** Before reporting done:
- run the excerpt's contract tests;
- run `make contract-test` and `make conventions-lint`;
- move a pin in `contracts/owner-pins.json` only to a pushed owner commit;
- record in the workplan result which rows and excerpt the work used.
5. **Parallel workers** use unique scratch file names and run State Hub syncs one at a time.

View file

@ -300,3 +300,10 @@ to this repository's owner contracts. Work: `emergent-security/EMERGENT-WP-0002`
6. **Leave a trail:** commits get assistant trailers from the hook; log State Hub progress with your agent
name and session.
<!-- END NETKINGDOM-SECURITY-QUICKSTART -->
## Cross-repo worker protocol (all agents)
Before a change that touches the bounded-execution path or spans repositories, read and follow
`.claude/rules/worker-protocol.md` (NK-WP-0058-T08). In short: name the contract-table rows, give every worker the
output of `python3 tools/contract-tests/excerpt.py <rows>`, keep one owner per step, and check the result with the
excerpt's contract tests, `make contract-test` and `make conventions-lint`.

View file

@ -10,6 +10,7 @@
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md
@.claude/rules/worker-protocol.md
<!-- BEGIN EMERGENT-SECURITY-PILOT -->
## NetKingdom situational knowledge pilot

View file

@ -12,7 +12,8 @@ RUN python -m pip install --no-cache-dir --require-hashes -r /app/bounded-execut
COPY canon/schemas/bounded-execution-admission_v0.1.schema.json /app/canon/schemas/
COPY tools/bounded-execution-admission/validate.py /app/tools/bounded-execution-admission/
COPY sso-mfa/k8s/synthetic-journey/journey.py /app/sso-mfa/k8s/synthetic-journey/
COPY bounded-executor/executor.py bounded-executor/operations.py bounded-executor/clients.py bounded-executor/cli.py /app/bounded-executor/
COPY bounded-executor/executor.py bounded-executor/operations.py bounded-executor/clients.py bounded-executor/cli.py \
bounded-executor/window_watch.py /app/bounded-executor/
USER 10001:10001
ENV PYTHONDONTWRITEBYTECODE=1
ENTRYPOINT ["python", "/app/bounded-executor/cli.py"]

View file

@ -0,0 +1,110 @@
"""window_watch.py on stubs (NK-WP-0058-T06, contract row O5): rule, both modes, and errors."""
import datetime as dt
import importlib.util
import pathlib
import sys
import unittest
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
spec = importlib.util.spec_from_file_location('window_watch', HERE.parent / 'window_watch.py')
W = importlib.util.module_from_spec(spec)
spec.loader.exec_module(W)
OPS = W.OPS
NOW = dt.datetime(2026, 11, 6, 3, 0, tzinfo=dt.timezone.utc)
def job(minutes_ago, active=0):
created = (NOW - dt.timedelta(minutes=minutes_ago)).isoformat().replace('+00:00', 'Z')
return {'metadata': {'creationTimestamp': created}, 'status': {'active': active} if active else {}}
class FakeBao:
def __init__(self, present=True, delete_works=True):
self.present, self.delete_works, self.calls = present, delete_works, []
def _call(self, method, path, body=None, auth=True):
self.calls.append((method, path))
assert path == W.ROLE_PATH, path # nothing but the write role is ever touched
if method == 'GET':
if not self.present:
raise OPS.OperationError('openbao GET 404')
return {'data': {}}
if method == 'DELETE':
self.present = not self.delete_works
return {}
raise AssertionError(method)
class FakeKube:
def __init__(self, jobs):
self.jobs, self.paths = jobs, []
def get(self, path):
self.paths.append(path)
return {'items': self.jobs}
class Rule(unittest.TestCase):
def test_decide(self):
cases = [
(False, [], 'ok'), # no role: nothing open
(True, [], 'stale'), # role, never a run: left open
(True, [job(3)], 'ok'), # role, run just created: a window in use
(True, [job(30, active=1)], 'ok'), # role, run still active
(True, [job(30)], 'stale'), # role, last run finished 30 min ago: left open
(True, [job(30), job(2)], 'ok'), # the newest run counts
]
for present, jobs, verdict in cases:
with self.subTest(present=present, jobs=len(jobs)):
self.assertEqual(W.decide(present, jobs, NOW)[0], verdict)
def test_run_jobs_selector_excludes_other_components(self):
kube = FakeKube([])
W.run_jobs(kube)
self.assertIn('labelSelector=app.kubernetes.io%2Fname%3Dbounded-executor%2C%21app.kubernetes.io%2Fcomponent',
kube.paths[0])
self.assertTrue(kube.paths[0].startswith('/apis/batch/v1/namespaces/sso/jobs?'))
class Modes(unittest.TestCase):
def test_ok_touches_nothing(self):
bao = FakeBao(present=False)
result, code = W.watch(bao, FakeKube([]), 'close', NOW)
self.assertEqual((result['status'], code), ('ok', 0))
self.assertEqual([m for m, _ in bao.calls], ['GET'])
def test_flag_reads_only_and_fails_the_job(self):
bao = FakeBao()
result, code = W.watch(bao, FakeKube([job(30)]), 'flag', NOW)
self.assertEqual((result['status'], code), ('flagged_stale', 3))
self.assertEqual([m for m, _ in bao.calls], ['GET'])
self.assertEqual(result['newest_age_s'], 1800)
def test_close_deletes_and_proves_absent(self):
bao = FakeBao()
result, code = W.watch(bao, FakeKube([]), 'close', NOW)
self.assertEqual((result['status'], code), ('closed_stale', 0))
self.assertEqual([m for m, _ in bao.calls], ['GET', 'DELETE', 'GET'])
self.assertFalse(bao.present)
def test_close_that_does_not_take_is_an_error(self):
result, code = W.watch(FakeBao(delete_works=False), FakeKube([]), 'close', NOW)
self.assertEqual((result['status'], result['reason'], code), ('error', 'write_role_still_present', 5))
def test_a_window_in_use_is_never_closed(self):
bao = FakeBao()
result, code = W.watch(bao, FakeKube([job(1, active=1)]), 'close', NOW)
self.assertEqual((result['status'], code), ('ok', 0))
self.assertTrue(bao.present)
def test_other_openbao_errors_are_not_read_as_absent(self):
class Denied(FakeBao):
def _call(self, method, path, body=None, auth=True):
raise OPS.OperationError('openbao GET 403')
with self.assertRaises(OPS.OperationError):
W.watch(Denied(), FakeKube([]), 'close', NOW)
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Window watch: the write role must not outlive a run (NK-WP-0058-T06; contract row O5).
python3 bounded-executor/window_watch.py --mode flag|close [--openbao-addr URL] [--jwt-file FILE]
Rule: the write role (CCR-2026-0058) exists, no bounded-executor run Job is active in sso, and none was created in
the last GRACE seconds. A role in that state is a window left open, as on 2026-11-04/05. Run Jobs carry
app.kubernetes.io/name=bounded-executor and no app.kubernetes.io/component label (request, preflight, journey and
netcheck pods carry one).
flag read-only: print `flagged_stale` and exit 3, so the watch Job fails and its status shows it
close delete the role, prove it is absent, print `closed_stale`, exit 0. Closing only reduces privilege: a run
that was about to start is refused before any effect.
The identity is ServiceAccount sso/nk-window-watch. It logs in to OpenBao role
net-kingdom-bounded-executor-window-watch, whose policy allows read (flag), or read and delete (close), on the
write role only, and it may list Jobs in sso. Prints one value-free line; exit 0 ok or closed, 3 flagged, 5 error.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
import urllib.parse
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import operations as OPS # noqa: E402
WRITE_ROLE = 'net-kingdom-bind-executor-svc-authelia-write'
WATCH_ROLE = 'net-kingdom-bounded-executor-window-watch'
ROLE_PATH = '/v1/auth/kubernetes/role/' + WRITE_ROLE
RUN_JOBS = 'app.kubernetes.io/name=bounded-executor,!app.kubernetes.io/component'
GRACE = 600 # seconds: the window opens moments before its run Job is created
def role_present(bao) -> bool:
try:
bao._call('GET', ROLE_PATH)
return True
except OPS.OperationError as error:
if str(error).endswith(' 404'):
return False
raise
def run_jobs(kube, namespace='sso'):
query = urllib.parse.urlencode({'labelSelector': RUN_JOBS})
return kube.get(f'/apis/batch/v1/namespaces/{namespace}/jobs?{query}').get('items', [])
def decide(present: bool, jobs: list, now: dt.datetime, grace: int = GRACE) -> tuple[str, dict]:
"""The rule, without I/O: ('ok' | 'stale', value-free facts)."""
active = sum(1 for j in jobs if (j.get('status') or {}).get('active'))
created = [dt.datetime.fromisoformat(j['metadata']['creationTimestamp'].replace('Z', '+00:00')) for j in jobs]
newest = max(created, default=None)
age = int((now - newest).total_seconds()) if newest else None
facts = {'write_role_present': present, 'run_jobs': len(jobs), 'active': active, 'newest_age_s': age}
stale = present and not active and (age is None or age > grace)
return ('stale' if stale else 'ok'), facts
def watch(bao, kube, mode: str, now: dt.datetime) -> tuple[dict, int]:
verdict, facts = decide(role_present(bao), run_jobs(kube), now)
if verdict == 'ok':
return {'status': 'ok', **facts}, 0
if mode == 'flag':
return {'status': 'flagged_stale', **facts}, 3
bao._call('DELETE', ROLE_PATH)
if role_present(bao):
return {'status': 'error', 'reason': 'write_role_still_present', **facts}, 5
return {'status': 'closed_stale', **facts}, 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0])
p.add_argument('--mode', choices=['flag', 'close'], required=True)
p.add_argument('--openbao-addr', default='http://openbao.openbao.svc:8200')
p.add_argument('--jwt-file', default='/var/run/secrets/openbao/token')
a = p.parse_args(argv)
bao = None
try:
bao = OPS.OpenBao(a.openbao_addr, k8s_role=WATCH_ROLE, jwt_file=a.jwt_file)
result, code = watch(bao, OPS.Kube(), a.mode, dt.datetime.now(dt.timezone.utc))
except Exception as error: # value-free: the clients' own errors carry fixed texts
reason = str(error) if isinstance(error, OPS.OperationError) else type(error).__name__
result, code = {'status': 'error', 'reason': reason}, 5
finally:
if bao is not None:
bao.revoke_self()
print(json.dumps({'watch': 'bounded-execution-window', 'mode': a.mode, **result}, sort_keys=True))
return code
if __name__ == '__main__':
sys.exit(main())

View file

@ -311,7 +311,8 @@ NK-WP-0058-T09, as of 2026-11-05. It uses the same revisions as part 1, plus acc
`sso/nk-bind-executor-audit` key `audit-token`. That is a removal or correction request to railiance-platform.
- audit-core's seeder hard-codes `/home/worsch/railiance-platform/...CCR-2026-0063...yaml` and the cluster UID
(`seed-nk-bounded-executor-sender.py:58-69`), for T07.
- the names appear in four repos (CCR, seeder, ESO manifest, rapp Job) and no test compares them.
- the names appear in four repos (CCR, seeder, ESO manifest, rapp Job); since T02,
`tools/contract-tests/tests/test_custody_names.py` compares them, and the rapp's CI checks the Job mount.
### C6: the executor's submitter profile
@ -368,6 +369,7 @@ evidence `docs/evidence/2026-11-04-nk56-t06-go-live/`, `2026-11-04-nk56-t06-pilo
| O2 | warden lanes ↔ child scripts | ops-warden: `warden access <lane> --exec` (silent, one command, fails on output, revokes on every path). Children: railiance-platform and audit-core silent scripts | the founder; seven lane invocations in the go-live runbook | none across repos | Seed r1 refused `token_env_must_be_unset`: warden strips `BAO_TOKEN` and `VAULT_TOKEN`, not `OPENBAO_TOKEN` | **keep**; **owner request** to ops-warden |
| O3 | Platform-admin session: lane vs shell | Two patterns: ops-warden lanes (railiance-platform, audit-core) and an interactive `bao login -method=oidc -path=netkingdom role=platform-admin` in the shell (net-kingdom `executor-lanes.py`, `pilot-run.sh`, T06 runbook 6c) | `executor-lanes.py attended_platform_admin()` | none | 2026-11-05: `window-close` failed `phase: identity, LaneError` in a shell without a login | **merge** into one pattern |
| O4 | Cluster access routes | Three in one runbook: `kubectl --kubeconfig ~/.kube/config-railiance01` (admin); `keycape-client.py` via `portal-client-rollout.py` hard-coding `/home/worsch/.kube/config-hosteurope` with `--server https://127.0.0.1:16444` (cluster UID pinned); the agent's default context (NetworkPolicy reads only) | founder, agents | none | none directly; costs founder setup | **merge** (T07: no hard-coded kubeconfigs) |
| O5 | Window watch: the write role outside a run | net-kingdom `bounded-executor/window_watch.py` (rule, modes); rapp CronJob `bounded-executor-window-watch` (schedule, identity); railiance-platform CCR for its OpenBao role (read, or read and delete, on the write role only) | the founder; the agent status identity (CronJob status) | `bounded-executor/tests/test_window_watch.py` (stubs); rapp packaging tests | 2026-11-04/05: the write role stayed open overnight (T11) | **keep** (new, NK-WP-0058-T06) |
| F1 | Founder ↔ runbook steps | net-kingdom T06 runbook, railiance-platform go-live, audit-core founder runbook | the founder | live only | about 50 copy-paste rounds; values carried by hand between steps (table below) | **merge** into T04's commands |
| F2 | Founder identity in the browser | informed-decision UI at aal2; the identity model (`admin` for production, `platform-root` kept out) | the founder; executor `_check_approval` | none | The first approval came as `uid=platform-root`, the identity signed in at that moment, and was refused | **keep** the refusal; add identity hygiene to T04 |
@ -404,6 +406,34 @@ Argo pin with live) cover the gap.
as silent children of the warden lane (`executor-lanes.py --quiet`). `ready` and `run` each need one login,
and no shell login is left open. `pilot-run.sh` is deleted.
### O4: one cluster route
- **Contract (T07, 2026-11-06):** tools and runbooks reach the cluster through one route, `NK_KUBECONFIG`,
defaulting to `~/.kube/config-railiance01`. No second kubeconfig file, absolute home path or `--server` override.
- **Exceptions:** node-local commands on railiance01 use the node's own `/etc/rancher/k3s/k3s.yaml`. They're listed
in `tools/conventions/lint.py` with their reasons.
- **The agent's own read-only route (T05):** `~/.kube/config-agent-status`, identity `sso/nk-agent-status`, 1 h
tokens.
- **Checked by:** the `one-cluster-route` rule of `tools/conventions/lint.py`.
### O5: window watch
- **Rule:** the write role `net-kingdom-bind-executor-svc-authelia-write` exists, no bounded-executor run Job is
active, and none was created in the last 10 minutes. Run Jobs are the Jobs labelled
`app.kubernetes.io/name=bounded-executor` without an `app.kubernetes.io/component` label. A role in that state is
a window left open.
- **Modes:**
- `flag` (read-only): the watch Job exits 3 and its status shows `Failed`.
- `close`: delete the role, prove it is absent, and exit 0 with `closed_stale`.
Closing only reduces privilege; a run that was about to start is then refused before any effect.
- **Identity:** ServiceAccount `sso/nk-window-watch`, projected token with audience `openbao`. Its OpenBao role is
`net-kingdom-bounded-executor-window-watch`, with a policy of `read` (flag) or `read` and `delete` (close) on
`auth/kubernetes/role/net-kingdom-bind-executor-svc-authelia-write` only, plus token self-lifecycle. Its
Kubernetes Role allows `list` on Jobs in `sso`. No Secrets.
- **Output:** one value-free line: `ok`, `flagged_stale` or `closed_stale`, the count of run Jobs and the age of the
newest one. It holds no token and no value.
### F1: the founder's steps, 2026-11-04/05
| Step | Authority it needs | Value carried by hand | Value-free result | Safe to repeat |
@ -526,7 +556,8 @@ The agent's default context can read NetworkPolicies in `sso`, `approval-engine`
| C1 | `tests/test_executor_lanes.py` runs railiance-platform's real helper modules | net-kingdom contract CI |
| C2, C3 | the readiness check: `case_type_bytes_match_broker`, `case_materialized` | net-kingdom contract CI |
| C4, C5 | `tools/contract-tests/tests/test_custody_names.py` | net-kingdom contract CI |
| O2–O4, F1, F2 | `tools/bounded-execution-run/tests/test_bx.py` (lane child silent, window always closed); conventions in T07 | net-kingdom contract CI |
| O5 | `bounded-executor/tests/test_window_watch.py` (rule and modes on stubs); rapp `tests/test_packaging.py` (CronJob shape) | net-kingdom contract CI; rapp CI |
| O2–O4, F1, F2 | `tools/bounded-execution-run/tests/test_bx.py` (lane child silent, window always closed); `tools/conventions/lint.py` (`one-cluster-route` for O4) | net-kingdom contract CI |
Not covered from source: OpenBao's ingress (rapp-openbao is private) and live drift from source, such as the
approval-engine policy that was live but not in its file. The readiness preflight Job catches unreachable endpoints

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Contract excerpt for a worker on a cross-repo change (NK-WP-0058-T08). Read-only.
python3 tools/contract-tests/excerpt.py E1 C5 O1 # rows of docs/bounded-execution-contract-table.md
Prints, for each row: its line in the table (owner and source of truth, consumer, mark), its detail sections (names,
paths, roles, payloads, exit codes, and the one owning script or file per step), and the contract tests that cover
it. A worker gets this excerpt before starting and checks its result with those tests and `make contract-test`.
An unknown row fails: a new interface gets its row in the table first.
"""
import re
import sys
from pathlib import Path
TABLE = Path(__file__).resolve().parents[2] / 'docs/bounded-execution-contract-table.md'
ROW = re.compile(r'^[EROCF]\d+$')
def ids_in(text):
"""Row ids named in a cell or heading, with ranges like O2–O4 expanded."""
found = set()
for first, last in re.findall(r'\b([EROCF]\d+)\s*[–-]\s*([EROCF]\d+)\b', text):
if first[0] == last[0]:
found |= {f'{first[0]}{n}' for n in range(int(first[1:]), int(last[1:]) + 1)}
return found | set(re.findall(r'\b[EROCF]\d+\b', text))
def sections(text):
"""(heading, body) for each ### section."""
parts = re.split(r'^(### .+)$', text, flags=re.M)
return [(parts[i], parts[i + 1].split('\n## ')[0]) for i in range(1, len(parts) - 1, 2)]
def excerpt(row_ids, text=None):
text = text if text is not None else TABLE.read_text()
out = []
for row in row_ids:
if not ROW.match(row):
raise SystemExit(f'not a row id: {row}')
line = next((l for l in text.splitlines() if l.startswith(f'| {row} |')), None)
if line is None:
raise SystemExit(f'{row}: no such row; add the interface to the table first')
out += [f'## {row}', '', line, '']
for heading, body in sections(text):
if row in ids_in(heading.split(':')[0]):
out += [heading, body.strip(), '']
coverage = [l for l in text[text.index('### Coverage by row'):].splitlines()
if l.startswith('| ') and not l.startswith('| Row') and row in ids_in(l.split('|')[1])]
out += ['**Contract tests:**', *(coverage or ['none recorded: add one with the change']), '']
return '\n'.join(out)
def main(argv=None):
rows = argv if argv is not None else sys.argv[1:]
if not rows:
raise SystemExit(__doc__)
print(excerpt(rows))
return 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -0,0 +1,67 @@
"""tools/contract-tests/excerpt.py (NK-WP-0058-T08): worker excerpts from the contract table."""
import importlib.util
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location('contract_excerpt', REPO / 'tools/contract-tests/excerpt.py')
excerpt = importlib.util.module_from_spec(spec)
spec.loader.exec_module(excerpt)
SYNTHETIC = """## Part 1
| ID | Interface | Owner |
| --- | --- | --- |
| E1 | lanes | platform |
| O2 | lane child | warden |
### E1: lanes
- role `r`, path `p`
### O2 and O3: sessions
- silent child
## Contract CI
### Coverage by row (2026-11-06)
| Row | Contract test | Runs in |
| --- | --- | --- |
| E1 | `tests/test_lanes.py` | CI |
| O2–O4 | `tests/test_bx.py` | CI |
"""
def test_ranges_and_lists():
assert excerpt.ids_in('O2–O4, F1, F2') == {'O2', 'O3', 'O4', 'F1', 'F2'}
assert excerpt.ids_in('E2, E3') == {'E2', 'E3'}
def test_row_details_and_tests():
text = excerpt.excerpt(['E1', 'O2'], SYNTHETIC)
assert '| E1 | lanes | platform |' in text and 'role `r`, path `p`' in text
assert '`tests/test_lanes.py`' in text
assert '### O2 and O3: sessions' in text and '`tests/test_bx.py`' in text
def test_unknown_rows_fail():
with pytest.raises(SystemExit, match='add the interface to the table first'):
excerpt.excerpt(['C9'], SYNTHETIC)
with pytest.raises(SystemExit, match='not a row id'):
excerpt.excerpt(['nonsense'], SYNTHETIC)
def test_every_row_of_the_real_table_has_details_and_a_contract_test():
text = excerpt.TABLE.read_text()
rows = [line.split('|')[1].strip() for line in text.splitlines()
if line.startswith('| ') and excerpt.ROW.match(line.split('|')[1].strip())
and '## Routing' not in text[:text.index(line)][-2000:]]
rows = sorted({r for r in rows})
assert rows, 'no rows found'
for row in rows:
out = excerpt.excerpt([row], text)
assert f'### {row}' in out or f' {row}:' in out or f'and {row}' in out, f'{row}: no detail section'
assert 'none recorded' not in out, f'{row}: no contract test recorded'