feat(scaffolding): §15N/§15O/§15P — discoverability, provenance, idempotency
15N: capabilities() now exposes full argument schemas (types, required fields, per-item shape, notes), lifecycle phase map + legal transitions, and scope-amendment authority — a fresh builder DISCOVERS the report_observation contract instead of trial-and-error (the T14 dict-vs-list + missing-entity mistakes become impossible). Held-out: schema-guided first report succeeds; schema-violating payload rejected. 15O: audit_scope is PROVENANCE-AWARE — residue paths present before the builder ran (or explicitly runtime-declared) stay exempt and recorded; NEW residue-looking paths appearing mid-session (.mimocode/evil, .git/config) are SCOPE VIOLATIONS. The hostile lookalike can no longer hide behind the T14 prefix rule. 15P: PROJECT_BUILD bootstrap twice = one project identity (no duplication); event replay after restart = readback-only recovery (idempotent by construction, contract-inspected).
This commit is contained in:
1 parent
292c72ec3d
commit
f5c8b07d15
4 files changed
+229
-10
No files matched your search
@@ -196,19 +196,42 @@ def _is_runtime_residue(path: str) -> bool:
|
||||
|
||||
|
||||
def audit_scope(project_dir: Path, before: dict,
|
||||
allowed: list[str]) -> dict:
|
||||
allowed: list[str], *,
|
||||
runtime_declared: set[str] | None = None) -> dict:
|
||||
"""§15O PROVENANCE-AWARE residue classification.
|
||||
|
||||
The T14 prefix rule (.git/.mimocode/node_modules) was too trusting: a
|
||||
builder deliberately writing `.mimocode/evil` would vanish from the
|
||||
audit. Provenance now decides:
|
||||
- residue paths present in the BEFORE snapshot existed before the
|
||||
builder ran (runtime bootstrap) → exempt, recorded;
|
||||
- NEW residue-matching paths appearing during the session are
|
||||
VIOLATIONS unless the harness explicitly declared them
|
||||
(runtime_declared = paths the runtime bootstrap itself announced
|
||||
creating).
|
||||
"""
|
||||
runtime_declared = runtime_declared or set()
|
||||
after = snapshot_files(project_dir)
|
||||
changed = sorted(set(after) - set(before))
|
||||
modified = sorted(k for k in after
|
||||
if k in before and after[k] != before[k])
|
||||
residue = sorted(f for f in changed + modified
|
||||
if _is_runtime_residue(f))
|
||||
violations = sorted(f for f in changed + modified
|
||||
if f not in allowed and not _is_runtime_residue(f))
|
||||
return {"created": [f for f in changed if not _is_runtime_residue(f)],
|
||||
"modified": [f for f in modified if not _is_runtime_residue(f)],
|
||||
"runtime_residue": residue,
|
||||
"scope_violations": violations,
|
||||
residue = []
|
||||
violations = []
|
||||
for f in changed + modified:
|
||||
if f not in allowed:
|
||||
if _is_runtime_residue(f):
|
||||
if f in before or f in runtime_declared:
|
||||
residue.append(f) # proven runtime provenance
|
||||
else:
|
||||
# residue-LOOKING path created during the session with
|
||||
# no provenance — the 15O hostile case: NOT exempt
|
||||
violations.append(f)
|
||||
else:
|
||||
violations.append(f)
|
||||
return {"created": [f for f in changed if f not in violations],
|
||||
"modified": [f for f in modified if f not in violations],
|
||||
"runtime_residue": sorted(set(residue)),
|
||||
"scope_violations": sorted(set(violations)),
|
||||
"in_scope": not violations}
|
||||
|
||||
|
||||
|
||||
@@ -214,7 +214,11 @@ class ConstructionInterface:
|
||||
"""§28 schema negotiation: builders detect the supported interface
|
||||
schema and operation set BEFORE calling operations. Also exposes the
|
||||
error-state vocabulary so builders can switch on states without
|
||||
parsing prose. Read-only; no contract required."""
|
||||
parsing prose. Read-only; no contract required.
|
||||
§15N: full argument schemas included — a fresh builder discovers
|
||||
required types/fields deterministically instead of trial-and-error
|
||||
(the T14 report_observation dict-vs-list + missing-entity mistakes
|
||||
are exactly what this prevents)."""
|
||||
return _envelope(
|
||||
"capabilities",
|
||||
success=True,
|
||||
@@ -236,6 +240,67 @@ class ConstructionInterface:
|
||||
"capabilities",
|
||||
],
|
||||
"error_states": sorted(v for k, v in vars(Errors).items() if not k.startswith("_")),
|
||||
# ── §15N argument schemas (discovery over guessing) ──
|
||||
"argument_schemas": {
|
||||
"submit_need": {
|
||||
"objective": {"type": "str", "required": True},
|
||||
"origin": {"type": "str", "default": "builder"},
|
||||
"constraints": {"type": "dict", "required": False,
|
||||
"notes": ("{'environment': {...}, "
|
||||
"'contracts': [...]} — "
|
||||
"environment missing "
|
||||
"degrades to "
|
||||
"INSUFFICIENT_EVIDENCE")},
|
||||
"affected_surface": {"type": "list[str]",
|
||||
"required": False},
|
||||
"evidence_refs": {"type": "list[str]",
|
||||
"required": True,
|
||||
"notes": ("empty list degrades "
|
||||
"fit resolution")},
|
||||
"approval_context": {"type": "str",
|
||||
"required": False},
|
||||
},
|
||||
"report_observation": {
|
||||
"observations": {
|
||||
"type": "list[dict]",
|
||||
"required": True,
|
||||
"item_required_fields": ["entity"],
|
||||
"item_optional_fields": ["source", "detail"],
|
||||
"notes": ("a LIST of dicts, each with an "
|
||||
"'entity' key — a bare dict or a "
|
||||
"dict without 'entity' is rejected "
|
||||
"(NEED_INCOMPLETE)"),
|
||||
},
|
||||
},
|
||||
"verify": {
|
||||
"changed_files": {"type": "list[str] | None",
|
||||
"required": False,
|
||||
"notes": ("scope-checked against "
|
||||
"the contract scope; "
|
||||
"out-of-scope files → "
|
||||
"SCOPE_VIOLATION")},
|
||||
},
|
||||
"transition": {
|
||||
"phase": {"type": "str", "required": True,
|
||||
"allowed": list(sc.PHASES)},
|
||||
},
|
||||
"close": {
|
||||
"observed_evidence": {
|
||||
"type": "list[dict]", "required": True,
|
||||
"notes": ("evidence INPUT only — closure is "
|
||||
"decided by verification truth, "
|
||||
"never by builder claim")},
|
||||
"scope_files": {"type": "list[str] | None",
|
||||
"required": False},
|
||||
},
|
||||
},
|
||||
"lifecycle": {
|
||||
"phases": list(sc.PHASES),
|
||||
"legal_transitions": {k: sorted(v) for k, v in
|
||||
sc.LEGAL_TRANSITIONS.items()},
|
||||
"scope_amendment": ("user-gated via amend_scope — "
|
||||
"builders cannot broaden scope"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -187,3 +187,47 @@ def test_15l_research_requests_never_become_project_builds():
|
||||
assert d.mode != "PROJECT_BUILD", \
|
||||
f"research intent '{t}' must not PROJECT_BUILD"
|
||||
assert d.mode in ("UNKNOWN", "NOT_CONSTRUCTION")
|
||||
|
||||
|
||||
def test_15n_fresh_builder_discovers_observation_schema():
|
||||
"""15N: a fresh builder with NO prior knowledge queries capabilities()
|
||||
first and learns the observation schema deterministically — the T14
|
||||
dict-vs-list + missing-entity mistakes become discoverable, not
|
||||
trial-and-error."""
|
||||
import tempfile
|
||||
from rune import construction_interface as ci
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
iface = ci.ConstructionInterface(builder="fresh-builder",
|
||||
project_store=str(tmp / "s"),
|
||||
project_id="t")
|
||||
iface.scaffold.init(purpose="15N probe")
|
||||
caps = iface.capabilities()["state"]
|
||||
obs_schema = caps["argument_schemas"]["report_observation"]["observations"]
|
||||
assert obs_schema["type"] == "list[dict]"
|
||||
assert "entity" in obs_schema["item_required_fields"]
|
||||
# guided by the schema, the builder's FIRST report is well-formed:
|
||||
# seed a spec contract so the fit gate resolves (same proven pattern
|
||||
# as the T14 fixture: the Need references the governed requirement)
|
||||
from rune import structural_store as ss
|
||||
doc = iface.scaffold.load()
|
||||
ss.add_entity(doc, id="contract:spec-15n", kind="contract",
|
||||
identities={"objective": "spec"}, evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="capability:planned-15n", kind="capability",
|
||||
maturity="DECLARED", evidence_refs=["e"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned-15n",
|
||||
to_id="contract:spec-15n", evidence_refs=["e"])
|
||||
iface.scaffold.commit(doc, what="seed 15n spec")
|
||||
sub = iface.submit_need(
|
||||
"build the 15n module",
|
||||
constraints={"environment": {"os": "linux"},
|
||||
"contracts": ["contract:spec-15n"]},
|
||||
evidence_refs=["e"], approval_context="15n")
|
||||
assert sub["success"], sub.get("errors")
|
||||
cid = sub["ids"]["contract_id"]
|
||||
r = iface.report_observation(cid, [
|
||||
{"entity": "m.js", "detail": "created"}])
|
||||
assert r["success"], r.get("errors")
|
||||
# and a schema-violating first attempt is REJECTED, not silently kept
|
||||
bad = iface.report_observation(cid, {"entity": "x", "detail": "dict"})
|
||||
assert not bad["success"]
|
||||
@@ -249,3 +249,90 @@ def test_14f_construction_kernel_shared_rune_internal_vs_external(tmp_path):
|
||||
# ALLOWED difference: authority identity
|
||||
assert ext.scaffold.project_id == "t14-hostile"
|
||||
assert rune_iface.scaffold.project_id == "rune"
|
||||
|
||||
|
||||
# ── 15O: residue provenance hardening ───────────────────────────────────────
|
||||
|
||||
def test_15o_residue_lookalike_malicious_write_is_caught(tmp_path):
|
||||
"""§15O hostile: a builder deliberately writes an exempt-looking path
|
||||
(.mimocode/evil, .git/config). Provenance — NOT the prefix — decides:
|
||||
the path appeared mid-session and was never declared by the runtime,
|
||||
so it is a SCOPE VIOLATION, not residue."""
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import t14_builder_session as t14
|
||||
proj = tmp_path / "proj"
|
||||
proj.mkdir()
|
||||
# runtime bootstrap BEFORE builder: .mimocode session dir exists
|
||||
(proj / ".mimocode").mkdir()
|
||||
(proj / ".mimocode" / "session.json").write_text("{}")
|
||||
before = t14.snapshot_files(proj)
|
||||
# builder writes its legit file AND a residue-lookalike malicious one
|
||||
(proj / "scheduler.js").write_text("ok")
|
||||
(proj / ".mimocode" / "evil").write_text("payload")
|
||||
(proj / ".git" / "config").parent.mkdir()
|
||||
(proj / ".git" / "config").write_text("tampered")
|
||||
audit = t14.audit_scope(proj, before, ["scheduler.js"])
|
||||
assert set(audit["scope_violations"]) == {".mimocode/evil", ".git/config"}
|
||||
assert not audit["in_scope"]
|
||||
assert audit["runtime_residue"] == [".mimocode/session.json"] or \
|
||||
".mimocode/session.json" not in audit["scope_violations"]
|
||||
|
||||
|
||||
def test_15o_preexisting_residue_stays_exempt(tmp_path):
|
||||
"""Residue with proven provenance (existed before the builder ran, or
|
||||
explicitly runtime-declared) remains exempt and is recorded."""
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import t14_builder_session as t14
|
||||
proj = tmp_path / "proj"
|
||||
(proj / ".mimocode").mkdir(parents=True)
|
||||
(proj / ".mimocode" / "session.json").write_text("{}")
|
||||
before = t14.snapshot_files(proj)
|
||||
(proj / "scheduler.js").write_text("ok")
|
||||
(proj / ".mimocode" / "new-runtime-file").write_text("x")
|
||||
audit = t14.audit_scope(
|
||||
proj, before, ["scheduler.js"],
|
||||
runtime_declared={".mimocode/new-runtime-file"})
|
||||
assert audit["in_scope"]
|
||||
assert ".mimocode/new-runtime-file" in audit["runtime_residue"]
|
||||
assert audit["scope_violations"] == []
|
||||
|
||||
|
||||
# ── 15P: null-action / idempotency proofs ───────────────────────────────────
|
||||
|
||||
def test_15p_project_build_bootstrap_twice_no_duplicate(tmp_path):
|
||||
"""15P: PROJECT_BUILD bootstrap executed twice must be a
|
||||
NOOP_ALREADY_SATISFIED-equivalent — one project identity, not two."""
|
||||
from rune import construction_activation as ca
|
||||
from rune import construction_mode as cm
|
||||
store = tmp_path / "scaffold"
|
||||
s = sc.Scaffold(str(store), "rune")
|
||||
s.init(purpose="15p probe")
|
||||
d1 = cm.classify_mode(
|
||||
"create a standalone practice scheduler with create/list/complete",
|
||||
target_project=None, project_exists=False,
|
||||
affected_surface=["scheduler.js"])
|
||||
from rune.construction_decision import Need
|
||||
need = Need(need_id="n1", objective="create a standalone practice "
|
||||
"scheduler with create/list/complete", origin="test",
|
||||
project="scheduler", constraints={}, affected_surface=[],
|
||||
evidence_refs=[], approval_context="15p")
|
||||
r1 = ca.activate(d1, need=need, scaffold=s, builder="probe")
|
||||
r2 = ca.activate(d1, need=need, scaffold=s, builder="probe")
|
||||
assert r1["mode"] == "PROJECT_BUILD" and r2["mode"] == "PROJECT_BUILD"
|
||||
# exactly ONE project scaffold — no duplicate identity
|
||||
projects = sorted((store / "projects").glob("*/current.json"))
|
||||
assert len(projects) == 1, f"bootstrap duplicated: {projects}"
|
||||
|
||||
|
||||
def test_15p_event_replay_after_restart_is_idempotent():
|
||||
"""15P + 15E: replaying a consumed operation event after a controller
|
||||
restart must be a no-op — the durable waitstore verdict stands."""
|
||||
from rune import event_wait as ew
|
||||
# (full daemon-backed proof lives in test_event_wait.py
|
||||
# test_15e_restart_after_event_persisted_waitstore_wins; this is the
|
||||
# semantic contract: resume_or_recover is a pure readback)
|
||||
# the function never mutates state — verify by contract inspection
|
||||
import inspect
|
||||
src = inspect.getsource(ew.resume_or_recover)
|
||||
assert "save(" not in src and "transition(" not in src, \
|
||||
"recovery must be readback-only (idempotent by construction)"
|
||||
Reference in new issue
Block a user