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:
@@ -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."),
|
||||
|
||||
@@ -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)}).")
|
||||
|
||||
Reference in New Issue
Block a user