diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5cd66c9..4beb72c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,21 +1,18 @@ name: validate on: - push: - # A TAG push must not start this workflow. `release.yml` calls it through - # `workflow_call`, and both runs land in the same concurrency group - # (`validate-${{ github.ref }}`) — so the push-triggered one cancels the - # release's own call, `release` and `publish` are SKIPPED for a `needs:` that - # was cancelled rather than failed, and the tag ships nothing while the run - # list shows a green validate beside it. Measured 2026-08-20 on - # `agent-stack v0.13.0`: validate green, release cancelled at 2s, npm still - # serving the previous version. It is a race, which is why the two siblings - # with the same shape happened to publish. - tags-ignore: ['**'] + # One explicit frozen candidate after local checks and independent review. pull_request: - # Callable, so `release.yml` gates a publish on THIS suite instead of a - # copy of it. The negative self-tests live here as steps; running them - # from the release workflow any other way means a second home per plant. + types: [ready_for_review] + schedule: + - cron: '0 23 * * *' + timezone: Europe/Warsaw + workflow_dispatch: + inputs: + candidate_reason: + description: 'Recorded explicit candidate exception with task and frozen SHA' + required: true + type: string workflow_call: permissions: @@ -41,6 +38,9 @@ jobs: - name: Structural validator run: python3 test/validate.py + - name: Owner board and release guard plants + run: python3 test/member_owner_gates_test.py + - name: Audit regressions (sherlock plan, one file per closed finding) run: npm run test:audit diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..4208672 --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,11 @@ +# Current work — R30 owner gates + +Objective: close agent-stack's missing own-board/release-ancestry checks under the +family's original R30. [Bounded packet and checks](evidence/runs/2026-10-13-r30-owner-gates.md) +is the entry for the next agent. Branch `fix/r30-owner-gates-1013`, basecb8f699. +Eight baseline defects reproduced; candidate16 controls pass after two orphan-row review corrections; independent review and local aggregate pass at7aa4dd3; +hosted integration and publication remain pending. No installed payload changes. + +Exact next task: frozen draft-to-ready candidate with genuine hosted checks, +then normal exact-head merge. Preserve nightly CI and normal required +checks. Commit/push and verify exact release source before hub re-pin/install. diff --git a/docs/evidence/backlog.md b/docs/evidence/backlog.md index b003bfa..ed0af63 100644 --- a/docs/evidence/backlog.md +++ b/docs/evidence/backlog.md @@ -45,9 +45,10 @@ row per line, appended, so two runs closing two rows do not land on the same lin | B-157 | **The back-links to the Fabric adapter run one way.** agent-interop now points to `fabric-agent-adapter`'s `creating-fabric-agents`, `adapting-projects-to-fabric` and `building-fabric-services`; of those, only `adapting-projects-to-fabric/SKILL.md:25-26` points back (wire questions). The adapter belongs to the Fabric owner: ask there for a pointer from the other two to agent-interop's tier doctrine. Nothing contradicts today — the adapter carries its own copy of the tier rule (`delegation.md:47-55`, 0.20.0). | wave 4 seam reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** — an ask to the Fabric adapter's owner | | B-158 | **The residue-ledger check misses an aliased temp-dir call.** `TEMP_MAKERS` (`test/validate.py`) excludes every dotted receiver but `tempfile.`, so `import tempfile as tf; tf.mkdtemp()` and `from tempfile import mkdtemp as m` pass. make-skill 0.31.0 ported this check and closed the gap there (any receiver, renamed from-imports refused, two negative controls); port that back. No current file here uses an alias. | make-skill W4-B seam reading, 2026-10-12 | 1 | 0 | 1 | **1** | **closed in v0.27.1 (2026-10-12)** — `TEMP_MAKERS` is make-skill 0.31.0's pattern (any single receiver, one-line renaming from-import refused); the alias and renamed-import negative controls in `test/validate.py` were watched failing on the old pattern (`B-158 self-test: a temp-dir call through a module alias (\`tf.\`) was not found`, `… a renamed from-import of a temp-dir maker was not found`), then passed. Evidence: verification.md, v0.27.1 | | AG-06a | **A temp tree created outside `test/` is still invisible.** `check_temp_trees_go_through_the_residue_ledger` reads this repository's own suite. A tree made by anything else — a script under `plugins/`, a future helper outside `test/` — is not accounted for, and `residue.report()` counts only what `workspace()` handed out. The mechanism catches a *reviewed* bypass; it does not observe `$TMPDIR`. Observing it would mean a run claiming ownership of directories it cannot prove it made, which is the reaping mistake `agent-sync` refuses by design. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 1 | 0 | 1 | **1** | **closed in v0.27.1 (2026-10-12)** for the static half: the check now reads every `*.py` the repository ships or runs (`test/` recursively, `plugins/`, `bin/`) with exact-path, exact-count, reasoned exemptions (`test/residue.py` 1, `agent-harness/scripts/audit_agent.py` 2). Its first run refused a real bypass, `test/audit_regressions/instruction_budget.py` line 21, now `residue.workspace()`. Plants: an aliased maker in a plugin script, a renamed from-import under `bin/`, a third maker in `audit_agent.py` — each refused by file and line. What stays true: the check reads source, it does not observe `$TMPDIR`; that half stays with AG-06b's content fingerprint. Evidence: verification.md, v0.27.1 | -| AG-06b | **The 2592 existing trees are reported and left alone.** `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \\| wc -l` → **2592** on 2026-08-20, of which **16 were added by this run's own verification** — one run of the leaking fixture before the port, and one of the v0.12.0 archive's copy of it. They are plain `tmpXXXXXXXX` and indistinguishable from every other program's, so they are named rather than swept. The identical fixture still leaks in **`seo-aeo-audit`**, which another agent holds; referred, not edited. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 2 | 0 | 1 | **2** | **closed in v0.27.1 (2026-10-12)** — the trees ARE identifiable by content, so they are reapable without claiming another program's directory: `python3 test/residue.py --reap-own` (dry run by default, `--apply` to remove) claims only an `mkdtemp()`-named, non-symlink child of the temp root holding exactly one of the fixture's five end-state file sets and its directory set byte for byte, plus optionally its own manifest, unchanged for 24 h; root, age floor and the removal window hardened after the PR #45 reading (verification.md, v0.27.1). `test/reap_own_test.py` 6/6 in a sandbox temp root; a planted permissive reaper (any tree holding `sub/b.sh`) was refused: `near-misses were claimed as own: ['tmpextrab01', 'tmpextraf01']`. Real `$TMPDIR`, dry, 2026-10-12: `0 tree(s) match`; the `find` count is 0 — the 2592 trees are already gone, and both leaking suites (`agent-stack`, `seo-aeo-audit`) now use `residue.workspace()`. Nothing in the real `$TMPDIR` was deleted | +| AG-06b | **The 2592 existing trees are reported and left alone.** `find "$TMPDIR" -maxdepth 4 -type f -path '*/copy/sub/b.sh' \| wc -l` → **2592** on 2026-08-20, of which **16 were added by this run's own verification** — one run of the leaking fixture before the port, and one of the v0.12.0 archive's copy of it. They are plain `tmpXXXXXXXX` and indistinguishable from every other program's, so they are named rather than swept. The identical fixture still leaks in **`seo-aeo-audit`**, which another agent holds; referred, not edited. | AG-06 port, 2026-08-20; plan R28 of the hub's 2026-10-11 integration waves | 2 | 0 | 1 | **2** | **closed in v0.27.1 (2026-10-12)** — the trees ARE identifiable by content, so they are reapable without claiming another program's directory: `python3 test/residue.py --reap-own` (dry run by default, `--apply` to remove) claims only an `mkdtemp()`-named, non-symlink child of the temp root holding exactly one of the fixture's five end-state file sets and its directory set byte for byte, plus optionally its own manifest, unchanged for 24 h; root, age floor and the removal window hardened after the PR #45 reading (verification.md, v0.27.1). `test/reap_own_test.py` 6/6 in a sandbox temp root; a planted permissive reaper (any tree holding `sub/b.sh`) was refused: `near-misses were claimed as own: ['tmpextrab01', 'tmpextraf01']`. Real `$TMPDIR`, dry, 2026-10-12: `0 tree(s) match`; the `find` count is 0 — the 2592 trees are already gone, and both leaking suites (`agent-stack`, `seo-aeo-audit`) now use `residue.workspace()`. Nothing in the real `$TMPDIR` was deleted | | B-159 | **`seo-aeo-audit` still has the AG-06a/B-158 hole.** Its temp-tree check reads less than the whole repository and misses aliased calls, and `test/negatives.py:195` makes a bare `tempfile.mkdtemp(prefix="tp-negatives-snap-")` outside any residue ledger (read on its `origin/main`, 2026-10-12). Port agent-stack 0.27.1's whole-repository scan, any-receiver pattern and exact-shape exemptions there, and route that snapshot through the ledger. | agent-stack PR #45 blind reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** — another repository; referred, not edited | | B-160 | **make-skill's temp-tree check reads `test/` only.** make-skill 0.31.0 has the any-receiver pattern but walks `test/` alone, so a maker in a shipped script is invisible there exactly as AG-06a described here. Port agent-stack 0.27.1's whole-repository scan with exact-path, exact-count, exact-shape exemptions back to make-skill. | agent-stack PR #45 blind reading, 2026-10-12 | 1 | 0 | 1 | **1** | **open** — another repository; referred, not edited | +| B-161 | **The owner validator accepted duplicate declarations and a disabled release ancestry check.** Eight real-validator plants passed on cb8f699; the same seam also hid an even-backslash Markdown pipe in AG-06b. | hub R30, 2026-10-13 | 2 | 0 | 1 | **2** | **open** — implementation candidate and16 process controls; independent review, integration and release pending. [Packet](runs/2026-10-13-r30-owner-gates.md) | ## Open, and why diff --git a/docs/evidence/runs/2026-10-13-r30-owner-full.json b/docs/evidence/runs/2026-10-13-r30-owner-full.json new file mode 100644 index 0000000..d4de620 --- /dev/null +++ b/docs/evidence/runs/2026-10-13-r30-owner-full.json @@ -0,0 +1,8 @@ +{ + "source": "7aa4dd36518bc853013aed775ccfe18dcec98a85", + "command": "npm test", + "exit_code": 0, + "log_sha256": "85d0cf29551ffe86b373c5a69c7a2131dcfff51ad64a1c48259b0342138faec9", + "log": "2026-10-13-r30-owner-full.log.gz", + "scope": "Local source validation; hosted integration and publication separate" +} diff --git a/docs/evidence/runs/2026-10-13-r30-owner-full.log.gz b/docs/evidence/runs/2026-10-13-r30-owner-full.log.gz new file mode 100644 index 0000000..5b5b5a1 Binary files /dev/null and b/docs/evidence/runs/2026-10-13-r30-owner-full.log.gz differ diff --git a/docs/evidence/runs/2026-10-13-r30-owner-gates.md b/docs/evidence/runs/2026-10-13-r30-owner-gates.md new file mode 100644 index 0000000..c9dc05e --- /dev/null +++ b/docs/evidence/runs/2026-10-13-r30-owner-gates.md @@ -0,0 +1,78 @@ +# R30 owner board and release gates — 2026-10-13 + +Base: `cb8f6993a61473f3974bc3af67925439ed4abb75`, version0.27.1. +Owner: agent-stack, leaseR30-GATES-1013. This is a bounded implementation packet +within the hub's original R30/B108/B109, not a closure of other members' gates. + +## Contract and reproduction + +The owner validator must refuse a malformed or duplicate declared board identity +and a release workflow whose tag ancestry guard can be bypassed. Preserve the +board's composite historical IDs, explicit umbrella namespace and summary table. +Use the already independently reviewed shared release parser, with no shell/YAML +semantic claim beyond its accepted structural grammar. Preserve release gates and +follow the current operator's nightly/explicit-candidate hosted-check policy. + +Before the edit, the real `test/validate.py` passed at the base. Eight isolated +process plants then each returned0: duplicate declaration, missing board, missing +header, absent ancestry command, shallow checkout, conditional guard, ignored +failure and early successful exit. The test expected exit1 with the intended +reason and reported8failures. Owned failure copies remain in the residue ledger; +private temporary paths are not copied here. + +## Implementation and controls + +`test/member_gates.py` is byte-identical to telegram-dev's shared helper at +`711576f89d06525ee94ad4aac0f779a5baeefdd5`; only its release parser and Markdown +cell/label primitives are used. The board adapter is member-specific: one primary +8column declaration table; grouped IDs expanded for duplicate detection; explicit +umbrella IDs kept in a separate namespace. The3column summary is a reference, +not a second declaration. Unsupported primary header shapes fail closed. + +The first port found AG-06b's historical command cell contained an even number +of backslashes before a pipe, making9 rendered cells. Its Markdown escape now +keeps8 cells without changing the displayed command. This correction is included +in the same board-contract change. + +`python3 test/member_owner_gates_test.py` now passes14 real-process controls: +11 defects plus3 valid controls. Added probes cover composite-ID overlap, the +real even-backslash defect and a missing separator; valid controls retain the +summary, distinguish hub/local IDs and accept bold formatting. Every mutation +asserts changed bytes. The suite requires the expected diagnostic as well as +exit1; it is wired into npm test and the hosted workflow. Its14 owned trees were +removed on successful completion; failures remain attributable by residue. + +## Delivery and bounds + +Independent review and full local/hosted gates remain pending. No version, tag, +registry or installation is changed. The new nightly schedule is23:00Warsaw; +only deliberate ready-for-review promotion runs an explicit candidate. Routine +push/open/synchronize full-suite triggers are removed under operator policy. + +Exact next task: independently review this candidate and its parser boundaries, +then run the owner full gate once, promote a frozen draft PR, integrate normally +and include it in a reviewed patch release. Do not advance the hub pin from this +unreleased branch. + +## Independent review correction + +At63c54b9 the peer inserted a blank line or HTML comment before B-159 and +then duplicated its identity in B-160. Both real validators returned0: the +adapter stopped before the later rows. Two added process controls reproduced +these failures locally. The guard now refuses ID-shaped orphan rows outside +the primary declaration table, except the explicitly identified3column summary. +The expanded suite passes16 controls with16 owned trees removed. This is a +new source revision requiring peer readback; earlier14PASS is historical. + +## Final bounded review and local aggregate + +Independent peer review of7aa4dd36518bc853013aed775ccfe18dcec98a85 passed: +16process controls replayed, plus5 independent actual-validator negatives +(blank/comment orphan rows, grouped overlap, formatted duplicate and duplicate +header). The shared helper remained byte-equal to its cited canonical source. + +`npm test` then passed at that exact source:17 structural checks,9 plant-guard +cases,16 owner controls,13 reap cases,11 installer cases and all audit regressions. +[Full receipt](2026-10-13-r30-owner-full.json) and compressed output bind the +checked source. This later documentation update does not retroactively change +that source identity. Genuine exact-head hosted CI and normal merge are next. diff --git a/package.json b/package.json index da91e7f..6a5916c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@ssheleg/agent-stack", "version": "0.27.1", "scripts": { - "test": "python3 test/validate.py && python3 test/plant_guard_test.py && python3 test/reap_own_test.py && node test/installer_test.js && npm run test:audit", + "test": "python3 test/validate.py && python3 test/plant_guard_test.py && python3 test/member_owner_gates_test.py && python3 test/reap_own_test.py && node test/installer_test.js && npm run test:audit", "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done" }, "description": "Production patterns for AI agent orchestrators \u2014 tool-calling loops, multi-stage pipelines with checkpoints, LLM provider routing with fallback, four-layer memory with confidence decay \u2014 plus the wallet side of reselling LLM access. This package is the installer CLI.", diff --git a/test/member_gates.py b/test/member_gates.py new file mode 100644 index 0000000..42cc61b --- /dev/null +++ b/test/member_gates.py @@ -0,0 +1,180 @@ +# shared-mechanism: sshlg-skills/member-gates-v1 +# diverges: none +# Copies: telegram-dev, xr-dev, web3d-dev; change and verify the family together. +"""Portable owner gates, shared byte-for-byte by the FW02 member wave. + +Stdlib only. Release validation checks the current workflow's structural contract, +not arbitrary YAML or shell semantics. Unknown workflow layouts fail closed. +""" +from pathlib import Path +import re +import shlex + + +def cells(line): + """Markdown pipes escaped by an odd number of backslashes are cell content.""" + result, start, escapes = [], 0, 0 + for index, char in enumerate(line): + if char == '|' and escapes % 2 == 0: + result.append(line[start:index].strip()) + start = index + 1 + escapes = escapes + 1 if char == '\\' else 0 + result.append(line[start:].strip()) + return result[1:-1] if not result[0] and not result[-1] else result + + +def label(value): + value = value.strip().strip('*` ') + link = re.fullmatch(r'\[([^\]]+)\]\([^)]*\)', value) + return (link.group(1) if link else value).strip('*` ') + + +def board_errors(text): + errors, seen, width, declares, count, fenced = [], {}, None, False, 0, False + pending = None + for number, line in enumerate(text.splitlines(), 1): + line = line.strip() + row = cells(line) if line.startswith('|') else [] + separator = bool(row) and all(re.fullmatch(r':?-{3,}:?', c) for c in row) + if pending is not None: + if separator and len(row) == width and line.endswith('|'): + pending = None + continue + errors.append(f'{pending}: board header needs an immediate matching separator') + pending = None + if line.startswith(('```', '~~~')): + fenced = not fenced + width = None + continue + if fenced: + continue + if not line.startswith('|'): + width = None + continue + first = label(row[0]) if row else '' + if first.lower() == 'row': + errors.append(f'{number}: board Row header unsupported; use ID for declarations') + width = None + continue + if first.lower() == 'id': + width, declares, pending = len(row), True, number + continue + if separator: + if width is not None: + errors.append(f'{number}: board misplaced separator') + continue + identifier = re.fullmatch(r'[A-Z]{1,4}-\d+[a-z]?', first) + if width is None: + if identifier: + errors.append(f'{number}: board row without ID header: {first}') + continue + if len(row) != width or not line.endswith('|'): + errors.append(f'{number}: board row width: {first} has {len(row)} cells; header declares {width}') + if not identifier: + errors.append(f'{number}: board invalid ID: {first!r}') + continue + if declares: + count += 1 + if first in seen: + errors.append(f'{number}: board duplicate ID {first}; first at {seen[first]}') + seen[first] = number + if pending is not None: + errors.append(f'{pending}: board header needs an immediate matching separator') + if not count: + errors.append('board has no declared rows') + return errors + + +def release_errors(text): + """Require the live release job's checkout and unconditional refusing guard. + + Bounded to this family's two-space job/six-space step layout; comments or + matching text in another job/step cannot satisfy this contract. + """ + errors = [] + active = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#')) + if re.search(r'^\s*(?:-\s*)?<<\s*:', active, re.M): + return ['release ancestry guard merge keys require explicit review'] + if re.search(r'^\s*(?:-\s*)?[\'"][^\'"\n]+[\'"]\s*:', active, re.M): + return ['release ancestry guard quoted mapping keys require explicit review'] + if len(re.findall(r'^jobs:', active, re.M)) != 1 or len(re.findall(r'^ release:', active, re.M)) != 1: + return ['release ancestry guard job is missing or ambiguous'] + if re.search(r'^(?:defaults:| defaults:)', active, re.M): + return ['release ancestry guard defaults require explicit review'] + job = re.search(r'^ release:\s*\n(?P.*?)(?=^ [\w-]+:|\Z)', active, re.M | re.S) + if not job: + return ['release job missing or unsupported layout'] + body = job.group('body') + if len(re.findall(r'^ steps:', body, re.M)) != 1: + return ['release ancestry guard steps are missing or ambiguous'] + if re.search(r'^ continue-on-error:\s*(?!false\s*(?:#.*)?$)\S', body, re.M): + errors.append('release ancestry failure ignored at job scope') + steps = re.split(r'(?=^ - )', body, flags=re.M)[1:] + full, checkouts, guards = [], [], [] + for index, step in enumerate(steps): + if re.search(r'^(?: - | )uses:\s*actions/checkout@', step, re.M): + checkouts.append(index) + options = re.findall(r'^ with:\s*\n((?: [^\n]*\n?|\n)*)', step, re.M) + depth = re.findall(r'^ fetch-depth:\s*(.*?)\s*(?:#.*)?$', options[0], re.M) if len(options) == 1 else [] + uses = re.findall(r'^(?: - | )uses:', step, re.M) + if (len(uses) == 1 and len(re.findall(r'^ with:', step, re.M)) == 1 + and not re.search(r'^(?: - | )if:', step, re.M) + and not re.search(r'^(?: - | )continue-on-error:\s*(?!false\s*(?:#.*)?$)\S', step, re.M) + and len(depth) == 1 and depth[0].strip('\'"') == '0'): + full.append(index) + run = re.search(r'^ run:\s*\|[-+]?\s*\n((?: [^\n]*\n?|\n)*)', step, re.M) + if not run: + continue + shell = run.group(1) + guard = re.search(r'^ if ! git merge-base --is-ancestor HEAD [\'"]origin/\$DEF[\'"]; then\s*\n(?P.*?)(?=^ fi\s*$)', shell, re.M | re.S) + if not guard: + continue + guards.append(index) + # This is the supported shell setup, not a substring hunt through arbitrary + # programs. Unknown setup/control flow must be reviewed with new fixtures. + prefix = [line.strip() for line in shell[:guard.start()].splitlines() if line.strip()] + expected = ['set -eu', 'DEF="$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch)"', + 'git fetch --no-tags origin "$DEF"'] + if prefix != expected or len(re.findall(r'^(?: - | )run:', step, re.M)) != 1: + errors.append('release ancestry guard has unsupported setup or duplicate run') + commands = [line.strip() for line in guard.group('failure').replace('\\\n', '').splitlines() if line.strip()] + try: + refusal = shlex.split(commands[-1], comments=True) == ['exit', '1'] + for command in commands[:-1]: + tokens = list(shlex.shlex(command, posix=True, punctuation_chars=';&|')) + if not tokens or tokens[0] != 'echo' or any(t and set(t) <= set(';&|') for t in tokens) or '$(' in command or '`' in command: + refusal = False + except (ValueError, IndexError): + refusal = False + if not refusal: + errors.append('release ancestry guard refusal has unsupported control flow') + shell_option = re.findall(r'^(?: - | )shell:\s*(.*?)\s*$', step, re.M) + if shell_option and shell_option != ['bash']: + errors.append('release ancestry guard shell requires explicit review') + if not re.search(r'^\s+exit 1\s*(?:#.*)?$', guard.group('failure'), re.M): + errors.append('release ancestry guard has no refusing exit') + if re.search(r'^(?: - | )if:', step, re.M): + errors.append('release ancestry step conditional') + if re.search(r'^(?: - | )continue-on-error:\s*(?!false\s*(?:#.*)?$)\S', step, re.M): + errors.append('release ancestry failure ignored at step scope') + if not guards: + errors.append('release ancestry guard missing from a live run block') + before = [i for i in checkouts if guards and i < guards[0]] + if not full or (guards and (not before or before[-1] not in full)): + errors.append('release full-history checkout missing before ancestry guard') + return errors + + +def check(root, fail): + root = Path(root) + for relative, inspect, missing in [ + ('docs/evidence/backlog.md', board_errors, 'board missing'), + ('.github/workflows/release.yml', release_errors, 'release workflow missing'), + ]: + try: + text = (root / relative).read_text(encoding='utf-8') + except OSError as exc: + fail(f'{relative}: {missing}: {exc.__class__.__name__}') + continue + for error in inspect(text): + fail(f'{relative}: {error}') diff --git a/test/member_owner_gates_test.py b/test/member_owner_gates_test.py new file mode 100644 index 0000000..58f3a42 --- /dev/null +++ b/test/member_owner_gates_test.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run bounded plants through the real owner validator; account for every copy.""" +from pathlib import Path +import re +import shutil +import subprocess +import sys +import residue + +ROOT = Path(__file__).resolve().parent.parent +BOARD = 'docs/evidence/backlog.md' +RELEASE = '.github/workflows/release.yml' + +def duplicate(text): + line = next(x for x in text.splitlines() if x.startswith('| B-160 |')) + return text.replace(line, line.replace('| B-160 |', '| B-159 |'), 1) + +cases = [ + ('duplicate declaration', BOARD, duplicate, 'board duplicate ID'), + ('group overlaps later declaration', BOARD, lambda s: s.replace('| B-160 |', '| AG-04 |', 1), 'board duplicate ID'), + ('even backslash pipe escape', BOARD, lambda s: s.replace('\\| wc -l', '\\\\| wc -l', 1), 'owner board row width'), + ('missing separator', BOARD, lambda s: s.replace('|---|---|---|---|---|---|---|---|', '', 1), 'matching separator'), + ('orphan after blank', BOARD, lambda s: duplicate(s.replace('| B-159 |','\n| B-159 |',1)), 'owner board orphan declaration'), + ('orphan after comment', BOARD, lambda s: duplicate(s.replace('| B-159 |','\n| B-159 |',1)), 'owner board orphan declaration'), + ('missing board', BOARD, lambda _: None, 'owner board missing'), + ('missing header', BOARD, lambda s: s.replace('| id | What | Source |', '| key | What | Source |', 1), 'owner board declaration header'), + ('missing ancestry', RELEASE, lambda s: s.replace('git merge-base --is-ancestor', 'git merge-base-removed --is-ancestor'), 'release ancestry guard'), + ('shallow checkout', RELEASE, lambda s: s.replace('fetch-depth: 0', 'fetch-depth: 1'), 'release full-history checkout'), + ('ancestry conditional', RELEASE, lambda s: s.replace(' - name: The tag must be reachable', ' - if: false\n name: The tag must be reachable'), 'release ancestry step conditional'), + ('ancestry ignores failure', RELEASE, lambda s: s.replace(' - name: The tag must be reachable', ' - continue-on-error: true\n name: The tag must be reachable'), 'release ancestry failure ignored'), + ('early successful exit', RELEASE, lambda s: s.replace(' if ! git merge-base', ' exit 0\n if ! git merge-base', 1), 'release ancestry guard'), +] +failures=[] +for label, relative, change, diagnostic in cases: + residue.open_case(label) + temporary=Path(residue.workspace('member-owner-gates')) + copy=temporary/'repo' + shutil.copytree(ROOT, copy, ignore=shutil.ignore_patterns('.git','node_modules','__pycache__','.agent-sync')) + target=copy/relative + before=target.read_text();after=change(before);assert before!=after,label + if after is None:target.unlink() + else:target.write_text(after) + p=subprocess.run([sys.executable,str(copy/'test/validate.py')],cwd=copy,capture_output=True,text=True) + output=p.stdout+p.stderr + if p.returncode!=1 or diagnostic not in output: + failures.append(f'{label}: expected exit1 and {diagnostic!r}; got exit{p.returncode}') + else:residue.close_case(label) +# The original summary repeats IDs deliberately; namespace and formatting must +# not create false duplicates or silently drop declarations. +controls = [ + ('unchanged board', lambda s:s), + ('foreign namespace distinct', lambda s:s.replace('| B-160 |','| B-118 |',1)), + ('formatted identifier', lambda s:s.replace('| B-160 |','| **B-160** |',1)), +] +for label, change in controls: + residue.open_case(label) + temporary=Path(residue.workspace('member-owner-control'));copy=temporary/'repo' + shutil.copytree(ROOT,copy,ignore=shutil.ignore_patterns('.git','node_modules','__pycache__','.agent-sync')) + target=copy/BOARD;target.write_text(change(target.read_text())) + p=subprocess.run([sys.executable,str(copy/'test/validate.py')],cwd=copy,capture_output=True,text=True) + if p.returncode:failures.append(label+' rejected: '+p.stdout+p.stderr) + else:residue.close_case(label) +for failure in failures:print('FAIL: '+failure) +print(f'Owner gates: {len(cases)+len(controls)} process controls; {len(failures)} failures') +residue.report() +raise SystemExit(bool(failures)) diff --git a/test/validate.py b/test/validate.py index 00fd8fd..5702ab8 100644 --- a/test/validate.py +++ b/test/validate.py @@ -1306,6 +1306,91 @@ def check_interop_points_to_the_estate_skills(): "HOW belongs to that skill, and this skill must name it rather than restate it") +def check_owner_board_and_release_guards(): + """The owner refuses malformed declarations and a bypassed tag ancestry gate. + + The board's first eight-column table declares identities. Its three-column + summary below references those same identities and is not a second declaration. + The portable grammar/release implementation is shared with the FW02 members. + """ + from member_gates import cells, label, release_errors + board = os.path.join(ROOT, "docs/evidence/backlog.md") + try: + lines = open(board, encoding="utf-8").read().splitlines() + except OSError: + fail("owner board missing or unreadable: docs/evidence/backlog.md") + lines = [] + header = ["id", "what", "source", "blast", "age", "effort", "p", "status"] + starts = [i for i, line in enumerate(lines) + if line.strip().startswith("|") and + [label(c).lower() for c in cells(line.strip())] == header] + if len(starts) != 1: + fail("owner board declaration header missing or ambiguous") + else: + start = starts[0] + end = start + 1 + while end < len(lines) and lines[end].strip().startswith("|"): + end += 1 + declared = {} + separator = cells(lines[start + 1].strip()) if start + 1 < end else [] + if len(separator) != len(header) or not all(re.fullmatch(r":?-{3,}:?", c) for c in separator): + fail("owner board declaration header needs its matching separator") + for number in range(start + 2, end): + row = cells(lines[number].strip()) + if len(row) != len(header) or not lines[number].strip().endswith("|"): + fail(f"owner board row width at line {number + 1}") + continue + # Historical grouped declarations and the explicitly qualified hub + # reference are distinct from the summary's repeated references. + identifier = label(row[0]) + namespace = "hub" if identifier.endswith(" (umbrella)") else "agent-stack" + if namespace == "hub": + identifier = identifier[:-11] + atoms = identifier.split(" + ") + if not all(re.fullmatch(r"[A-Z]{1,4}-(?:[0-9]+[a-z]?|[A-Z][0-9]*)", atom) for atom in atoms): + fail(f"owner board invalid ID at line {number + 1}") + continue + for atom in atoms: + key = (namespace, atom) + if key in declared: + fail(f"board duplicate ID {namespace}:{atom} at line {number + 1}; first at {declared[key]}") + declared[key] = number + 1 + if not declared: + fail("owner board has no declarations") + # Blank/comment boundaries cannot make later declarations disappear. + # Only this explicitly identified summary table may reference identities + # outside the primary table. Other ID-shaped rows need a declared home. + summary = False + for number, line in enumerate(lines): + if start <= number < end: + continue + stripped = line.strip() + if not stripped.startswith("|"): + summary = False + continue + row = cells(stripped) + headings = [label(c).lower() for c in row] + if headings == ["id", "what", "why it is still open"]: + summary = True + continue + identity = label(row[0]) if row else "" + if re.match(r"[A-Z]{1,4}-(?:[0-9]+|[A-Z])", identity): + if not summary or len(row) != 3: + fail(f"owner board orphan declaration at line {number + 1}") + path = os.path.join(ROOT, ".github/workflows/release.yml") + try: + workflow = open(path, encoding="utf-8").read() + except OSError: + fail("owner release workflow missing or unreadable") + return + for error in release_errors(workflow): + fail(".github/workflows/release.yml: " + error) + ci = open(os.path.join(ROOT, ".github/workflows/validate.yml"), encoding="utf-8").read() + if not re.search(r"^\s*run: python3 test/member_owner_gates_test\.py\s*$", ci, re.M): + fail("owner gate plants missing from validate.yml") + + +check_owner_board_and_release_guards() check_ledger_tables_keep_their_shape() check_shipped_front_matter_survives_a_real_reader() check_interop_points_to_the_estate_skills()