fix(harness): state-integrity pass — reopen order, atomic decision batch, bash anti-bypass

Audit findings 5.1-5.3.

5.1 `phase reopen` now appends `phase_reopened` BEFORE the artifact
teardown: a crash between the two used to leave later-phase artifacts
deleted with the ledger still at the old phase (resume entered a phase
missing its artifacts). The inverse half-state — reopened with stale later
artifacts — is benign. Order locked by tests/test_phase_reopen_order.py.

5.2 New `tht decision add-batch --doc -`: N substantive decisions in ONE
atomic ledger write (meta types and cte_approved stay on `decision add`;
strictest min-phase enforced). reviewer_schema_linking now builds the
complete curation set and persists it with a single add-batch call — a
mid-loop failure can no longer leave the audit ledger half-written, and a
retry cannot duplicate the first K decisions.

5.3 The anti-bypass hook now also blocks BASH mutations of protected
state (`echo >> review_decisions.jsonl`, `sed -i` on the manifest,
`cat > tht-gate.js`, python open('w'), mv/rm/tee/…): FORBIDDEN only
covered tht subcommands and the write/edit hook only covered pi's own
tools. Read-only access (cat/grep/tail/ls) stays allowed.

Also: knownDecisionTypes is defensive — a workflow meta declaring NO
emits at all (older tht, minimal stubs) skips pre-validation instead of
rejecting every substantive type; with emits present, unknown types are
still rejected before the widget (new L1 test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 01:51:38 +02:00
co-authored by Claude Fable 5
parent f772ef9dca
commit 1ab0015460
8 changed files with 454 additions and 36 deletions
+51
View File
@@ -47,6 +47,57 @@ def add_join_set_cmd(
)
@decision_app.command("add-batch")
def add_batch_cmd(
session: str = typer.Option(..., "--session", help="Id della sessione."),
doc: str = typer.Option(..., "--doc", help="Array JSON di decisioni; usa '-' per stdin."),
config: Path = CONFIG_OPT,
) -> None:
"""Registra N decisioni sostanziali con un'unica scrittura atomica del ledger.
Per i gate che persistono una curation completa (es. schema linking:
table_* + column_*): un fallimento a metà non lascia mai il ledger
mezzo-scritto — o tutte o nessuna. I tipi meta (phase_*,
decision_retracted) e cte_approved restano su `decision add`."""
from tht.decisions import DecisionInput
cfg = _load_config_or_exit(config)
load_session_or_exit(cfg, session)
excluded = {
"phase_approved", "phase_auto_approved", "phase_reopened",
"phase_skipped", "decision_retracted", "cte_approved",
}
try:
raw = sys.stdin.read() if doc == "-" else Path(doc).read_text()
payload = json.loads(raw)
if not isinstance(payload, list) or not payload:
raise ValueError("il documento deve essere un array JSON non vuoto")
decisions = [DecisionInput.model_validate(item) for item in payload]
for decision in decisions:
if decision.type in excluded:
raise ValueError(
f"tipo '{decision.type}' non ammesso in batch (usa 'decision add')"
)
except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
typer.secho(
f"ERRORE: batch di decisioni non valido: {error}", fg=typer.colors.RED, err=True,
)
raise typer.Exit(code=1) from error
from tht.cli.phase_cmd import require_phase_or_exit
from tht.workflow import load_workflow
workflow = load_workflow()
require_phase_or_exit(
cfg, session, max(workflow.decision_min_phase(d.type) for d in decisions)
)
records = session_repository(cfg).append_decisions(session, decisions)
typer.secho(
f"OK: registrate {len(records)} decisioni in un'unica scrittura atomica.",
fg=typer.colors.GREEN,
)
@decision_app.command("add")
def add_cmd(
session: str = typer.Option(..., "--session", help="Id della sessione."),
+6 -1
View File
@@ -122,10 +122,15 @@ def reopen_cmd(
from tht.teardown import teardown_snapshot
repository = session_repository(cfg)
report = teardown_snapshot(repository, snapshot, phase)
# Ledger FIRST, teardown after: a crash between the two used to leave later-phase
# artifacts deleted with the ledger still at the old phase (resume entered a phase
# whose expected artifacts were gone). The inverse half-state — reopened with stale
# later artifacts — is benign: the folded phase wins and the workflow overwrites
# them as it re-progresses.
repository.append_decisions(
session, [{"type": "phase_reopened", "subject": f"phase:{phase}"}]
)
report = teardown_snapshot(repository, snapshot, phase)
for f in report.deleted_files:
typer.echo(f" eliminato artefatto: {f}")
typer.echo(f"Tornati alla Fase {phase} ({load_workflow().phase_name(phase)}).")