From c63aa398d635b09997545bd1ae8b6e5cfa8bb60e Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 6 Nov 2026 01:36:09 +0100 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 943911@bnt-lap001 Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0 --- .claude/rules/worker-protocol.md | 18 ++++ AGENTS.md | 7 ++ CLAUDE.md | 1 + bounded-executor/Containerfile | 3 +- bounded-executor/tests/test_window_watch.py | 110 ++++++++++++++++++++ bounded-executor/window_watch.py | 98 +++++++++++++++++ docs/bounded-execution-contract-table.md | 35 ++++++- tools/contract-tests/excerpt.py | 62 +++++++++++ tools/contract-tests/tests/test_excerpt.py | 67 ++++++++++++ 9 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 .claude/rules/worker-protocol.md create mode 100644 bounded-executor/tests/test_window_watch.py create mode 100644 bounded-executor/window_watch.py create mode 100644 tools/contract-tests/excerpt.py create mode 100644 tools/contract-tests/tests/test_excerpt.py diff --git a/.claude/rules/worker-protocol.md b/.claude/rules/worker-protocol.md new file mode 100644 index 0000000..bce362b --- /dev/null +++ b/.claude/rules/worker-protocol.md @@ -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 ` 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. diff --git a/AGENTS.md b/AGENTS.md index d413c94..3d19a0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. + +## 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 `, keep one owner per step, and check the result with the +excerpt's contract tests, `make contract-test` and `make conventions-lint`. diff --git a/CLAUDE.md b/CLAUDE.md index cb8e80a..d869aed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ @.claude/rules/repo-boundary.md @.claude/rules/credential-routing.md @.claude/rules/agents.md +@.claude/rules/worker-protocol.md ## NetKingdom situational knowledge pilot diff --git a/bounded-executor/Containerfile b/bounded-executor/Containerfile index 92528e8..b06a117 100644 --- a/bounded-executor/Containerfile +++ b/bounded-executor/Containerfile @@ -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"] diff --git a/bounded-executor/tests/test_window_watch.py b/bounded-executor/tests/test_window_watch.py new file mode 100644 index 0000000..a996779 --- /dev/null +++ b/bounded-executor/tests/test_window_watch.py @@ -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() diff --git a/bounded-executor/window_watch.py b/bounded-executor/window_watch.py new file mode 100644 index 0000000..0a643af --- /dev/null +++ b/bounded-executor/window_watch.py @@ -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()) diff --git a/docs/bounded-execution-contract-table.md b/docs/bounded-execution-contract-table.md index 96cf6b0..c61bcce 100644 --- a/docs/bounded-execution-contract-table.md +++ b/docs/bounded-execution-contract-table.md @@ -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 --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 diff --git a/tools/contract-tests/excerpt.py b/tools/contract-tests/excerpt.py new file mode 100644 index 0000000..4347132 --- /dev/null +++ b/tools/contract-tests/excerpt.py @@ -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()) diff --git a/tools/contract-tests/tests/test_excerpt.py b/tools/contract-tests/tests/test_excerpt.py new file mode 100644 index 0000000..a4502d0 --- /dev/null +++ b/tools/contract-tests/tests/test_excerpt.py @@ -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'