Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcae0bb10 | ||
|
|
a8d1b98332 | ||
|
|
74272842aa | ||
|
|
aa67119d55 | ||
|
|
9681baa318 | ||
|
|
8a6b051149 | ||
|
|
6bb5507d33 | ||
|
|
8692f1dc76 | ||
|
|
78d70def5f | ||
|
|
c9260ace18 | ||
|
|
2a7e9dcfcf | ||
|
|
e3263ef9b1 | ||
|
|
ce328a8080 | ||
|
|
4df1f6f23f | ||
|
|
7484e175be | ||
|
|
d84060cc02 | ||
|
|
b102225d4e | ||
|
|
f86b2d1eec | ||
|
|
307b0bf814 | ||
|
|
d776f286de | ||
|
|
621f88c425 | ||
|
|
ef6811ff58 | ||
|
|
a4f02f227c | ||
|
|
5b315ac7c7 | ||
|
|
90bd2b3285 | ||
|
|
1bb4ea86b8 | ||
|
|
f98416e94a | ||
|
|
b3465176f8 | ||
|
|
aab5159ccc | ||
|
|
80f10b9be7 | ||
|
|
9d75e51b22 | ||
|
|
b6113fad7b | ||
|
|
f765365d03 | ||
|
|
b94a3aef61 | ||
|
|
8ca4628694 | ||
|
|
535c290917 | ||
|
|
6bc31c513e | ||
|
|
386137dc14 | ||
|
|
f87adc0d18 | ||
|
|
ed3a73a0a9 | ||
|
|
600008482d | ||
|
|
4bfa4f9b0a | ||
|
|
1d6213cd74 | ||
|
|
9c3f5f0a1d | ||
|
|
c0594ce192 | ||
|
|
dc0ac7b92e | ||
|
|
5e0a2def9d | ||
|
|
049a8dbd86 | ||
|
|
0a9b0648cd | ||
|
|
da2ec4df0e |
No files matched your search
@@ -213,38 +213,17 @@ jobs:
|
||||
env.write(f"RELEASE_VERSION={NEW}\n")
|
||||
env.write("AUTO_BUMPED=true\n")
|
||||
PYBUMP
|
||||
- name: Create release from merged pull request
|
||||
- name: Create evidence-derived release (SCAFFOLD-TRANCHE-009B)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
PR_BODY: ${{ gitea.event.pull_request.body }}
|
||||
RELEASE_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases
|
||||
LIST_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases?limit=50
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
# Skip idempotently if a release for this tag already exists (retry/replay safety).
|
||||
if curl --fail --silent "$LIST_API_URL" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c 'import json,sys,os; v=os.environ["RELEASE_VERSION"]; exists=[r for r in json.load(sys.stdin) if r.get("tag_name")=="v"+v]; sys.exit(0 if exists else 1)'; then
|
||||
echo "Release v$RELEASE_VERSION already exists — skipping creation"
|
||||
exit 0
|
||||
fi
|
||||
python3 - <<'PY' > /tmp/rune-release.json
|
||||
import json, os
|
||||
version = os.environ["RELEASE_VERSION"]
|
||||
body = f"## {os.environ['PR_TITLE']}\n\n{os.environ.get('PR_BODY', '')}"
|
||||
print(json.dumps({
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": "master",
|
||||
"name": f"Rune v{version}",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/rune-release.json \
|
||||
"$RELEASE_API_URL"
|
||||
rm -f /tmp/rune-release.json
|
||||
# ONE note-generation authority: the tested release_notes module.
|
||||
# The old PR-title/body paste path (which produced empty '##'
|
||||
# bodies on direct-push releases) is REMOVED — governed evidence
|
||||
# from the scaffold store is the source, not PR prose.
|
||||
python3 scripts/release_history_cli.py init \
|
||||
--version "$RELEASE_VERSION" \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--store store/scaffold/current.json
|
||||
@@ -42,15 +42,39 @@ jobs:
|
||||
echo "::error::CI qualification timeout on $DEPLOY_SHA — deployment refused"
|
||||
exit 1
|
||||
|
||||
- name: Stage, verify, promote
|
||||
- name: Stage, verify, promote (monotonic)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/deploy_stage.py
|
||||
|
||||
- name: Post-deploy runtime verification (the consuming path)
|
||||
- name: Promotion truthfulness check
|
||||
id: promotion_truth
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
CURRENT_TARGET=$(readlink /mnt/workdrive/rune-code/current || true)
|
||||
if [ "$CURRENT_TARGET" = "/mnt/workdrive/rune-code/${SHA:0:12}" ]; then
|
||||
echo "PROMOTED: current == candidate ($SHA)"
|
||||
echo "promoted=true" >> "$GITEA_OUTPUT"
|
||||
else
|
||||
# Not promoted. deploy_stage exit 0 + non-matching current =
|
||||
# NOOP_ALREADY_CURRENT or STALE_SUPERSEDED — truthful non-error
|
||||
# terminal states (a newer build already won). Do NOT claim
|
||||
# promotion; the runtime-verification step below only runs for
|
||||
# real promotions.
|
||||
echo "NOT PROMOTED: current -> $CURRENT_TARGET (superseded/noop — current is authoritative)"
|
||||
echo "promoted=false" >> "$GITEA_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post-deploy runtime verification (only for real promotions)
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
CURRENT_TARGET=$(readlink /mnt/workdrive/rune-code/current || true)
|
||||
if [ "$CURRENT_TARGET" != "/mnt/workdrive/rune-code/${SHA:0:12}" ]; then
|
||||
echo "SKIP runtime verification: this job did not promote (superseded/noop)"
|
||||
exit 0
|
||||
fi
|
||||
# Exactly what the launcher execs: cd <current>/src, import rune.
|
||||
RUNE_HOME=/mnt/workdrive/rune RUNE_MEMORY_DIR=/home/nick/.config/opencode/memory \
|
||||
/home/nick/.venvs/memory/bin/python3 -c "
|
||||
@@ -62,3 +86,43 @@ jobs:
|
||||
assert rune.__version__ == info['version']
|
||||
print('RUNTIME VERIFIED:', info)
|
||||
"
|
||||
- name: Release-history convergence refresh (SCAFFOLD-TRANCHE-009B)
|
||||
# §0 T10 CORRECTION: gate on the PROMOTION OUTPUT, not success() —
|
||||
# success() is true even for non-promoting (stale/superseded) jobs,
|
||||
# and a stale candidate writing its own HEAD as deployed_sha would
|
||||
# corrupt Gitea history. Only the SHA that ACTUALLY became
|
||||
# deployment:current may refresh the release record.
|
||||
if: ${{ success() && steps.promotion_truth.outputs.promoted == 'true' }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
# §3/§10: a later SHA of the SAME version successfully became
|
||||
# deployment:current → refresh the SAME Gitea release with the
|
||||
# converged identity + newly-governed facts. Idempotent. Failure
|
||||
# is VISIBLE (non-zero) but does NOT roll back valid software —
|
||||
# documentation failure never outweighs deployment validity (§5);
|
||||
# retry converges.
|
||||
#
|
||||
# Deployed identity comes from the DEPLOYMENT AUTHORITY (current
|
||||
# symlink readback), never from git rev-parse HEAD alone — the two
|
||||
# must agree (the promotion check proved they do) and the authority
|
||||
# is what is recorded.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
CURRENT_TARGET=$(readlink /mnt/workdrive/rune-code/current || true)
|
||||
DEPLOYED_IDENTITY=$(python3 -c "
|
||||
import json
|
||||
print(json.load(open('$CURRENT_TARGET/src/rune/_build_identity.json'))['commit'])")
|
||||
if [ "$DEPLOYED_IDENTITY" != "$SHA" ]; then
|
||||
echo "::error::deployment authority disagrees with candidate SHA — refusing to refresh release history"
|
||||
exit 1
|
||||
fi
|
||||
VERSION=$(python3 -c "import sys; sys.path.insert(0,'src'); import rune; print(rune.__version__)")
|
||||
python3 scripts/release_history_cli.py refresh \
|
||||
--version "$VERSION" \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--store "$GITHUB_WORKSPACE/store/scaffold/current.json" \
|
||||
--release-sha "$SHA" \
|
||||
--deployed-sha "$DEPLOYED_IDENTITY" \
|
||||
|| echo "::warning::release-history refresh FAILED — release may be stale; retry converges (deployment unaffected)"
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.66.35**
|
||||
**Current release: v0.66.48**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "GENERATED by rune.spine \u2014 compatibility view for legacy umbrella consumers. DO NOT EDIT: structural truth lives in store/spine/current.json; edits here are flagged by spine drift.",
|
||||
"_generated_by_spine": true,
|
||||
"generated_at": "2026-09-13T03:10:20Z",
|
||||
"generated_at": "2026-09-14T01:52:07Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
@@ -218,14 +218,21 @@
|
||||
"capability_fit",
|
||||
"cognitive_budgeter",
|
||||
"complexity_scorer",
|
||||
"construction_decision",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"layout_contract",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"structural_store",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
"witness"
|
||||
],
|
||||
"depends_on": [
|
||||
|
||||
@@ -146,3 +146,130 @@ evidence/changed/next from the store alone — no transcripts.
|
||||
| repo binding (identity model) | UNIT-PROVEN (local git fixture) |
|
||||
| external-repo live proof | BLOCKED ON USER REPO SELECTION |
|
||||
| Scaffolding overall | substrate only — capability-fit discovery, gap decisions, toolchain selection, patterns, maintenance TUI all NOT BUILT (data seams exist) |
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding Tranche 4 — Live Need → Decision → Construction Contract (2026-09-13)
|
||||
|
||||
_The adoption tranche: a real Need now flows automatically through the
|
||||
Tranche 2/3 mechanisms and produces a governed Construction Contract. The
|
||||
model no longer has to remember the procedure — it is durable Scaffolding._
|
||||
|
||||
## Live flow
|
||||
|
||||
NEED (reference to Task/Intent — pointer, never a copy)
|
||||
↓ normalization (gaps recorded, never invented)
|
||||
↓ structural discovery + fit + resolution (Spine + Project Scaffold)
|
||||
↓ toolchain decision (existing project toolchain wins)
|
||||
↓ layout obligations (USER-LOCKED authority inherited)
|
||||
↓ user-approval gate (material changes require approval; approved
|
||||
bounded work never re-asks)
|
||||
↓ DecisionTrace (durable, model-independent)
|
||||
↓ generated Construction Contract → existing build/verify pipeline
|
||||
|
||||
## Authority map (unchanged — the path CONNECTS, never duplicates)
|
||||
|
||||
| authority | owns |
|
||||
|---|---|
|
||||
| Task/Intent | why work exists |
|
||||
| Rune Spine | Rune structural truth |
|
||||
| Project Scaffold | external-project structural/construction truth |
|
||||
| Layout Contract | desired interface constraints (USER-LOCKED = user authority) |
|
||||
| Construction Contract | governed build obligations/history |
|
||||
| Git / Gitea | history / execution |
|
||||
|
||||
## Boundary honesty
|
||||
|
||||
- **ADAPT/research**: the live path records a research ROUTE
|
||||
(TARGETED_EVIDENCE_PROBE / FULL_RESEARCH_PIPELINE) when evidence is
|
||||
insufficient — a seam, NOT an executed pipeline call. No fake
|
||||
integration claimed.
|
||||
- **BUILD_NEW** is never a fallback: insufficient evidence blocks it;
|
||||
normalization gaps block it; merge precedence across authorities
|
||||
requires every consulted authority to agree.
|
||||
- **No second task engine**: one decision function + one trace object.
|
||||
|
||||
## Contract-quality repair (Tranche 3 defects)
|
||||
|
||||
Closure now distinguishes:
|
||||
|
||||
predicted_existing_affected planned changes to existing entities
|
||||
expected_new entities the build PLANNED to create
|
||||
correctly_predicted predicted AND independently observed
|
||||
predicted_but_unaffected predicted, NOT observed
|
||||
expected_new_observed planned creation happened
|
||||
expected_new_missing planned creation did NOT happen
|
||||
genuinely_unexpected_new observed, planned by neither list
|
||||
|
||||
Prediction cannot self-certify observation: the observed set comes ONLY
|
||||
from caller evidence (git/tests/build/deploy). Legacy keys (missed/
|
||||
needless/observed) preserved — old contracts readable without migration.
|
||||
|
||||
## Layout-obligation inheritance
|
||||
|
||||
A change touching a UI component/region/implementation inherits its Layout
|
||||
Contract as a build obligation. USER-LOCKED obligations state that changing
|
||||
placement requires explicit user authorization. Unrelated backend changes
|
||||
inherit nothing. USER-LOCKED drift is never reconciled by editing the
|
||||
contract — code adapts.
|
||||
|
||||
## Maturity (honest)
|
||||
|
||||
- Need intake: EXERCISED (real live proof)
|
||||
- decision orchestration: LIVE-PROVEN for the bounded live flow
|
||||
- capability discovery adoption: EXERCISED
|
||||
- fit/gap adoption: EXERCISED
|
||||
- toolchain adoption: EXERCISED
|
||||
- contract generation: LIVE-PROVEN (one real generated contract)
|
||||
- contract-quality prediction: EXERCISED (repaired semantics, one live closure)
|
||||
- layout-obligation inheritance: UNIT-PROVEN (fixtures) + real TUI binding
|
||||
- Scaffolding overall: adoption path live for one bounded flow — NOT
|
||||
general autonomous construction, NOT universal discovery/toolchain
|
||||
intelligence, NOT Scaffolding complete.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Scaffolding Tranche 9 — Release History + Evidence Correction (2026-09-13)
|
||||
|
||||
_Gitea Releases are now an evidence-derived human-readable history of
|
||||
Rune's evolution. Plus the Tranche 8 builder-evidence correction._
|
||||
|
||||
## Release semantics (audited)
|
||||
|
||||
| surface | meaning |
|
||||
|---|---|
|
||||
| VERSION | semver in pyproject/init (e.g. 0.66.44) |
|
||||
| TAG | v<VERSION>, created by auto-version at the BUMP commit |
|
||||
| release commit | the auto-bump SHA — NOT necessarily final state |
|
||||
| converged deployed identity | last commit deployed under the same VERSION |
|
||||
|
||||
Same-version convergence commits (closure/accounting/lock fixes landing and
|
||||
deploying after the bump) are normal; the notes record BOTH the tag commit
|
||||
and the final deployed identity, distinction exposed honestly.
|
||||
|
||||
## Mechanism
|
||||
|
||||
`release_notes.py`: deterministic evidence extraction from governed records
|
||||
(closed contracts, resolved maintenance, contract-quality records) →
|
||||
structured ReleaseNote → Markdown → Gitea API (POST create / PATCH update).
|
||||
Idempotent: publishing twice for a tag UPDATES, never duplicates. Notes are
|
||||
historical documentation — NOT Memory, NOT structural authority.
|
||||
|
||||
## T8 evidence correction (§1)
|
||||
|
||||
The T8 report claimed a close-from-verifying refusal + reactive superseding
|
||||
transition. Durable checkpoints show NO superseding transition and the
|
||||
phase map permits verifying→closed; live output showed first-attempt close
|
||||
success. The claim was RETRACTED with full provenance (original claim,
|
||||
correction, reason, evidence preserved in the contract record) and
|
||||
recorded as process-defect:unsupported-causal-claim. MiMo adoption
|
||||
maturity survives on the SUPPORTED evidence: scope constrained file edits,
|
||||
pattern obligations forced preservation proof, interface-reported
|
||||
observations, fresh resume.
|
||||
|
||||
## Maturity
|
||||
|
||||
Release-note generator: LIVE-PROVEN (real Gitea, 10 releases). Backfill:
|
||||
0.66.35→0.66.44 carrying historical-backfill provenance. Pre-0.66.35
|
||||
history: skipped (weak evidence — honest limitation).
|
||||
@@ -9,6 +9,7 @@ new surface._
|
||||
|---|---|---|
|
||||
| `rune_tui.py` | The main Textual application — chat pane, work pane, status bar (version label + refresh), footer, dialogs | The known giant (file-size debt tracked in henxels); composition root for all UI |
|
||||
| `tui_validator.py` | Import-allowlist validation for TUI code | Drift defect tracked in #139 (allowlist vs `__future__`/relative imports) |
|
||||
| `tui_backlog.py` | Not a surface — the durable TUI Upgrade Backlog ledger (outstanding projection/control work per backend feature) | SCAFFOLD-TRANCHE-007; ledger only, never structural authority |
|
||||
|
||||
## Adding a TUI surface
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.66.35"
|
||||
version = "0.66.48"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ certifi==2026.7.22
|
||||
# httpx
|
||||
chardet==7.6.0
|
||||
# via diff-cover
|
||||
coverage==7.16.0
|
||||
coverage==7.16.1
|
||||
# via rune (pyproject.toml)
|
||||
diff-cover==10.5.1
|
||||
# via rune (pyproject.toml)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""construction_guards.py — deterministic guards for observed process
|
||||
defects (SCAFFOLD-TRANCHE-006 §21-22).
|
||||
|
||||
Two real defect classes from recent sessions:
|
||||
|
||||
A. DURABLE-STORE MUTATION WITHOUT PERSISTENCE (Tranche 5: closure script
|
||||
mutated the contract in memory, never called save(); the next read saw
|
||||
stale state). Guard: a durable-boundary operation verifies by
|
||||
RELOAD/READBACK, not by successful in-memory mutation.
|
||||
|
||||
B. BLIND SOURCE-EDIT NO-OP (multiple sessions: python str.replace() on
|
||||
ruff-reformatted files silently matched nothing; the script reported
|
||||
success while changing nothing). Guard: an automated edit verifies its
|
||||
POSTCONDITION — expected text present / old text absent — and a
|
||||
zero-match replacement is an explicit, truthful no-op, never success.
|
||||
|
||||
Not a generic editor framework. Two small, deterministic postcondition
|
||||
checks at the boundaries where the defects actually occurred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["verify_persisted", "assert_persisted", "apply_source_edit", "EditNoOp", "EditPostconditionFailed"]
|
||||
|
||||
|
||||
class EditNoOp(Exception):
|
||||
"""The edit matched zero targets — an explicit, truthful no-op."""
|
||||
|
||||
|
||||
class EditPostconditionFailed(Exception):
|
||||
"""The edit claimed success but the expected postcondition is absent."""
|
||||
|
||||
|
||||
# ── Guard A: durable mutation verified by readback ──────────────────────────
|
||||
|
||||
|
||||
def verify_persisted(path: Path | str, selector, expect) -> bool:
|
||||
"""Reload the durable store from disk and check the expectation against
|
||||
the RELOADED state. `selector(reloaded_doc) -> value`; pass == value
|
||||
equals `expect` (or expect(value) is truthy when callable).
|
||||
|
||||
The reloaded document is parsed as JSON when the file is JSON-shaped;
|
||||
otherwise the raw text is passed to the selector (plain-text stores).
|
||||
|
||||
This is the Tranche 5 defect guard: an in-memory mutation that was
|
||||
never save()d fails here, because the disk state is what gets read.
|
||||
"""
|
||||
raw = Path(path).read_text(encoding="utf-8")
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError:
|
||||
doc = raw
|
||||
value = selector(doc)
|
||||
if callable(expect):
|
||||
return bool(expect(value))
|
||||
return value == expect
|
||||
|
||||
|
||||
def assert_persisted(path: Path | str, selector, expect, *, what: str = "durable state") -> None:
|
||||
"""verify_persisted that raises on mismatch — for use right after a
|
||||
save() at durable boundaries."""
|
||||
if not verify_persisted(path, selector, expect):
|
||||
raise AssertionError(
|
||||
f"persistence verification FAILED for {what}: reloaded state "
|
||||
f"does not match expectation — the mutation may not have been "
|
||||
f"saved (Tranche 5 defect class A)"
|
||||
)
|
||||
|
||||
|
||||
# ── Guard B: source edit with postcondition ─────────────────────────────────
|
||||
|
||||
|
||||
def apply_source_edit(
|
||||
text: str,
|
||||
old: str,
|
||||
new: str,
|
||||
*,
|
||||
expected_count: int | None = None,
|
||||
postcondition: str | None = None,
|
||||
allow_noop: bool = False,
|
||||
) -> str:
|
||||
"""Apply a source edit WITH postcondition verification (Guard B).
|
||||
|
||||
- zero matches: raises EditNoOp (truthful no-op) unless allow_noop=True
|
||||
- expected_count given: match count must equal it
|
||||
- postcondition given: the RESULT must contain it, else
|
||||
EditPostconditionFailed
|
||||
Returns the new text. Never claims success for a change that did not
|
||||
happen."""
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
if allow_noop:
|
||||
return text # truthful, explicit no-op — caller records it
|
||||
raise EditNoOp(
|
||||
f"edit matched 0 targets — nothing changed (Tranche 5 defect "
|
||||
f"class B: str.replace silently no-ops on reformatted files)"
|
||||
)
|
||||
if expected_count is not None and count != expected_count:
|
||||
raise EditPostconditionFailed(f"edit matched {count} targets, expected {expected_count}")
|
||||
result = text.replace(old, new)
|
||||
if postcondition and postcondition not in result:
|
||||
raise EditPostconditionFailed(
|
||||
"postcondition text absent from result — edit did not achieve its expected effect"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def apply_source_edit_file(
|
||||
path: Path | str, old: str, new: str, *, expected_count: int | None = None, postcondition: str | None = None
|
||||
) -> int:
|
||||
"""File-level convenience: read, apply_source_edit, write back, then
|
||||
VERIFY BY READBACK (both guards composed). Returns match count."""
|
||||
p = Path(path)
|
||||
text = p.read_text(encoding="utf-8")
|
||||
result = apply_source_edit(text, old, new, expected_count=expected_count, postcondition=postcondition)
|
||||
p.write_text(result, encoding="utf-8")
|
||||
# Guard A composed: the written file must contain the postcondition
|
||||
if postcondition:
|
||||
assert_persisted(p, lambda t: t, lambda t: postcondition in t, what=f"edit of {p.name}")
|
||||
return text.count(old)
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""deploy_promotion.py — monotonic promotion authority for Rune deploys.
|
||||
|
||||
SCAFFOLD contract DEPLOY-MONOTONIC-001. Root cause of the 2026-09-13
|
||||
deploy-order race (deploys 7355/7359): deploy_stage.py promoted
|
||||
unconditionally in job-finish order — no comparison against the currently
|
||||
deployed commit, no lock, no truthful outcome for a stale candidate.
|
||||
|
||||
This module owns the PROMOTION decision. The invariant:
|
||||
|
||||
current never regresses along the canonical release branch.
|
||||
|
||||
Ordering authority: GIT ANCESTRY on the canonical release branch
|
||||
(refs/heads/master). Rune's release flow is linear history — every deploy
|
||||
candidate is a master push (tranche commit or its auto-bump descendant).
|
||||
candidate == deployed commit -> NOOP_ALREADY_CURRENT (idempotent)
|
||||
candidate is DESCENDANT of it -> allowed (normal forward deploy)
|
||||
candidate is ANCESTOR of it -> STALE_SUPERSEDED (refuse; truthful)
|
||||
no ancestry relationship -> REFUSED_DIVERGENT (fail closed)
|
||||
deployed commit unknown/unreadable-> REFUSED_UNKNOWN_CURRENT (fail closed)
|
||||
|
||||
Mechanism (smallest that provides the invariant, per brief §4-5):
|
||||
1. promotion-time stale check — ancestry comparison against the commit
|
||||
current ACTUALLY points to at promotion time (not at job start);
|
||||
2. an flock'd critical section around the read-compare-write of
|
||||
current/last-good so two concurrent promotions cannot interleave
|
||||
between the ancestry check and the symlink swap.
|
||||
Workflow-level concurrency is NOT relied on (brief §5): the fence lives at
|
||||
the promotion operation itself.
|
||||
|
||||
Truthful outcomes (brief §8) — build/stage success != promotion success:
|
||||
PROMOTED candidate became current (verified below)
|
||||
NOOP_ALREADY_CURRENT same commit already deployed; nothing mutated
|
||||
STALE_SUPERSEDED a newer commit is already current; nothing mutated
|
||||
REFUSED_DIVERGENT ordering unprovable; fail closed
|
||||
REFUSED_UNKNOWN_CURRENT deployed commit unreadable; fail closed
|
||||
FAILED_* verification/staging failures (in deploy_stage)
|
||||
|
||||
A stale or refused outcome is a NON-ERROR terminal state for the release
|
||||
pipeline (the newer build simply won) but MUST NOT print "PROMOTED" and
|
||||
MUST NOT mutate current/last-good. deploy_stage's exit code and the
|
||||
workflow's post-deploy runtime verification enforce this: the workflow
|
||||
asserts current == candidate after a PROMOTED outcome, so a stale outcome
|
||||
makes the job report honestly rather than masquerade.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
|
||||
RELEASE_BRANCH = "refs/heads/master"
|
||||
|
||||
OUTCOME_PROMOTED = "PROMOTED"
|
||||
OUTCOME_NOOP = "NOOP_ALREADY_CURRENT"
|
||||
OUTCOME_STALE = "STALE_SUPERSEDED"
|
||||
OUTCOME_DIVERGENT = "REFUSED_DIVERGENT"
|
||||
OUTCOME_UNKNOWN_CURRENT = "REFUSED_UNKNOWN_CURRENT"
|
||||
|
||||
|
||||
# ── pure decision logic (unit-tested; no I/O) ──────────────────────────────
|
||||
|
||||
|
||||
def decide_promotion(candidate_sha: str, deployed_sha: str, ancestry: str) -> str:
|
||||
"""Pure promotion decision from a precomputed ancestry relationship.
|
||||
ancestry is one of: 'same' | 'descendant' | 'ancestor' | 'divergent' |
|
||||
'unknown'. Fail-closed: anything not provably allowed is refused."""
|
||||
if candidate_sha == deployed_sha or ancestry == "same":
|
||||
return OUTCOME_NOOP
|
||||
if ancestry == "descendant":
|
||||
return OUTCOME_PROMOTED
|
||||
if ancestry == "ancestor":
|
||||
return OUTCOME_STALE
|
||||
if ancestry == "divergent":
|
||||
return OUTCOME_DIVERGENT
|
||||
return OUTCOME_UNKNOWN_CURRENT
|
||||
|
||||
|
||||
def ancestry_of(candidate_sha: str, deployed_sha: str, repo: Path, fetch_on_divergent: bool = True) -> str:
|
||||
"""Classify candidate vs deployed using git merge-base --is-ancestor on
|
||||
the canonical release branch's repository. Returns 'unknown' on any git
|
||||
failure (fail-closed callers treat that as refusal).
|
||||
|
||||
Observed live 2026-09-13 (deploy 7449, SHA 535c2909): a DELAYED older
|
||||
candidate's checkout cannot contain commits newer than itself — its
|
||||
local object store lacks the deployed commit entirely, so ancestry
|
||||
classifies 'divergent' and fail-closed refuses. Correct refusal, wrong
|
||||
reason: the candidate IS an ancestor. When the local classification is
|
||||
divergent, fetch origin's canonical branch first (best-effort; the
|
||||
deploy workflow's checkout has credentials configured) and re-classify.
|
||||
If still divergent afterwards, the refusal stands with better
|
||||
evidence."""
|
||||
|
||||
def _is_ancestor(a: str, b: str) -> bool:
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "merge-base", "--is-ancestor", a, b],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
if candidate_sha == deployed_sha:
|
||||
return "same"
|
||||
if _is_ancestor(candidate_sha, deployed_sha):
|
||||
return "ancestor" # candidate is OLDER than what's deployed
|
||||
if _is_ancestor(deployed_sha, candidate_sha):
|
||||
return "descendant" # candidate is NEWER (normal forward deploy)
|
||||
if fetch_on_divergent:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "fetch", "origin", "master", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass # best-effort: classification below still fails closed
|
||||
if _is_ancestor(candidate_sha, deployed_sha):
|
||||
return "ancestor"
|
||||
if _is_ancestor(deployed_sha, candidate_sha):
|
||||
return "descendant"
|
||||
return "divergent"
|
||||
|
||||
|
||||
def read_deployed_commit(deploy_root: Path = DEPLOY_ROOT) -> str:
|
||||
"""Read the commit the `current` symlink ACTUALLY points to right now —
|
||||
from the deployed build identity, the deployment authority (never from
|
||||
a stale job-start artifact). Empty string if unreadable."""
|
||||
current = deploy_root / "current"
|
||||
try:
|
||||
target = Path(os.readlink(current))
|
||||
except OSError:
|
||||
return ""
|
||||
identity = target / "src" / "rune" / "_build_identity.json"
|
||||
try:
|
||||
data = json.loads(identity.read_text(encoding="utf-8"))
|
||||
return str(data.get("commit", ""))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
# ── the fenced promotion operation ──────────────────────────────────────────
|
||||
|
||||
|
||||
def promote(
|
||||
candidate_sha: str, stage: Path, tmp_stage: Path, repo: Path, deploy_root: Path = DEPLOY_ROOT
|
||||
) -> tuple[str, str]:
|
||||
"""Atomically decide + promote under an exclusive lock.
|
||||
|
||||
Returns (outcome, detail). ONLY the PROMOTED outcome mutates
|
||||
current/last-good, and it does so inside the same critical section as
|
||||
the decision — no interleaving window between check and swap.
|
||||
|
||||
Crash-safety ordering (brief §16): the stage rename happens BEFORE the
|
||||
lock (a staged-but-unpromoted tree is inert), the symlink swap is a
|
||||
single rename(2) via a temporary link, so current is never absent and
|
||||
never half-updated. last-good is refreshed BEFORE the swap only from a
|
||||
value that was genuinely current a moment ago.
|
||||
"""
|
||||
deploy_root.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = deploy_root / ".promotion.lock"
|
||||
with open(lock_path, "w") as lock_fd:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
deployed = read_deployed_commit(deploy_root)
|
||||
if not deployed:
|
||||
return (OUTCOME_UNKNOWN_CURRENT, "deployed commit unreadable — fail closed")
|
||||
rel = ancestry_of(candidate_sha, deployed, repo)
|
||||
outcome = decide_promotion(candidate_sha, deployed, rel)
|
||||
if outcome != OUTCOME_PROMOTED:
|
||||
return (outcome, f"deployed={deployed[:12]} ancestry={rel}")
|
||||
|
||||
# --- mutate (still inside the lock) ---
|
||||
current = deploy_root / "current"
|
||||
last_good = deploy_root / "last-good"
|
||||
prev_target = os.readlink(current) if current.is_symlink() else ""
|
||||
if prev_target and (deploy_root / "last-good").is_symlink():
|
||||
(deploy_root / "last-good").unlink()
|
||||
if prev_target:
|
||||
_atomic_symlink(deploy_root, "last-good", prev_target)
|
||||
if stage.exists():
|
||||
# re-deploying an already-staged SHA: restage over it
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(stage)
|
||||
os.rename(tmp_stage, stage)
|
||||
_atomic_symlink_target(deploy_root, "current", stage)
|
||||
return (OUTCOME_PROMOTED, f"current -> {stage.name}")
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _atomic_symlink(deploy_root: Path, name: str, target: str) -> None:
|
||||
"""Symlink swap that never leaves `name` absent: write temp link, rename
|
||||
over the final name (rename(2) is atomic on the same filesystem)."""
|
||||
_atomic_symlink_target(deploy_root, name, Path(target))
|
||||
|
||||
|
||||
def _atomic_symlink_target(deploy_root: Path, name: str, target: Path) -> None:
|
||||
tmp = deploy_root / f".{name}.swap-{os.getpid()}"
|
||||
if tmp.is_symlink() or tmp.exists():
|
||||
tmp.unlink()
|
||||
tmp.symlink_to(target)
|
||||
os.replace(tmp, deploy_root / name)
|
||||
+36
-24
@@ -3,12 +3,15 @@
|
||||
|
||||
Called by .gitea/workflows/deploy.yml. Stages src/ into
|
||||
/mnt/workdrive/rune-code/<short-sha>, stamps _build_identity.json, verifies
|
||||
the staged tree (import + SHA assert + authority smoke), then ATOMICALLY
|
||||
promotes the `current` symlink and records `last-good` (safe rollback:
|
||||
the previous deployment remains available).
|
||||
the staged tree (import + SHA assert + authority smoke), then promotes
|
||||
through the MONOTONIC promotion authority (deploy_promotion.py): an older
|
||||
candidate finishing after a newer promoted one is fenced out (STALE_SUPERSEDED)
|
||||
and reports truthfully — build/stage success != promotion success.
|
||||
|
||||
Exit 0 = deployed and verified. Non-zero = deployment refused; the previous
|
||||
deployment stays current.
|
||||
Exit 0 = PROMOTED and verified, or NOOP_ALREADY_CURRENT, or a truthful
|
||||
STALE_SUPERSEDED (newer build already current; nothing mutated).
|
||||
Non-zero = deployment refused (divergent/unknown ordering, verification
|
||||
failure); the previous deployment stays current.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -119,25 +122,34 @@ print("VERIFIED:", json.dumps(info))
|
||||
shutil.rmtree(tmp_stage, ignore_errors=True)
|
||||
return 1
|
||||
|
||||
# --- promote (atomic) + last-known-good ------------------------------
|
||||
DEPLOY_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
current = DEPLOY_ROOT / "current"
|
||||
if current.is_symlink():
|
||||
last_good = DEPLOY_ROOT / "last-good"
|
||||
if last_good.is_symlink():
|
||||
last_good.unlink()
|
||||
last_good.symlink_to(os.readlink(current))
|
||||
if current.is_symlink() or current.exists():
|
||||
current.unlink()
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage) # re-deploying an already-staged SHA: restage
|
||||
os.rename(tmp_stage, stage)
|
||||
current.symlink_to(stage)
|
||||
print(f"PROMOTED: {short}")
|
||||
print("current ->", os.readlink(current))
|
||||
if (DEPLOY_ROOT / "last-good").is_symlink():
|
||||
print("last-good ->", os.readlink(DEPLOY_ROOT / "last-good"))
|
||||
return 0
|
||||
# --- promote: FENCED through the promotion authority -----------------
|
||||
# SCAFFOLD contract DEPLOY-MONOTONIC-001: promotion is monotonic. The
|
||||
# ancestry check + lock live in deploy_promotion.py; this script only
|
||||
# handles the truthful outcome reporting. A stale candidate must NOT
|
||||
# claim promotion success — build/stage success != promotion success.
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
import deploy_promotion # noqa: E402
|
||||
|
||||
outcome, detail = deploy_promotion.promote(sha, DEPLOY_ROOT / short, tmp_stage, repo)
|
||||
|
||||
if outcome == deploy_promotion.OUTCOME_PROMOTED:
|
||||
print(f"PROMOTED: {short}")
|
||||
print("current ->", os.readlink(DEPLOY_ROOT / "current"))
|
||||
if (DEPLOY_ROOT / "last-good").is_symlink():
|
||||
print("last-good ->", os.readlink(DEPLOY_ROOT / "last-good"))
|
||||
return 0
|
||||
if outcome == deploy_promotion.OUTCOME_NOOP:
|
||||
print(f"NOOP_ALREADY_CURRENT: {short} is the deployed commit")
|
||||
print(detail)
|
||||
return 0
|
||||
if outcome == deploy_promotion.OUTCOME_STALE:
|
||||
print(f"STALE_SUPERSEDED: {short} is older than the deployed commit — promotion refused (monotonic promotion)")
|
||||
print(detail)
|
||||
print("current unchanged:", os.readlink(DEPLOY_ROOT / "current"))
|
||||
return 0 # truthful non-error terminal state: the newer build won
|
||||
# divergent / unknown-current: fail closed
|
||||
print(f"REFUSED ({outcome}): {short} — {detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""release_history_cli.py — thin workflow entry points for release history.
|
||||
|
||||
SCAFFOLD-TRANCHE-009B §4: the workflows invoke THIS tested module; no
|
||||
note-building logic lives in YAML. Two moments:
|
||||
|
||||
init RELEASE INITIALIZATION — auto-version creates the evidence-
|
||||
derived Gitea release when the version/tag appears.
|
||||
refresh RELEASE CONVERGENCE REFRESH — deploy promotes a later SHA of
|
||||
the same version; the SAME release is updated with the final
|
||||
deployed identity + newly-governed facts. Idempotent.
|
||||
|
||||
Fail-closed boundary (§5/§16): `init` exits non-zero on publication
|
||||
failure — Rune cannot silently create another empty historical release.
|
||||
`refresh` ALSO exits non-zero (visible failure + retry converges), but the
|
||||
workflow marks it non-blocking for deployment validity: valid software is
|
||||
never rolled back over documentation. Failure state is visible either way.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
from rune import release_notes as rn # noqa: E402
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
tok = os.environ.get("GITEA_TOKEN", "")
|
||||
if tok:
|
||||
return tok
|
||||
# local/dev fallback: git credential store (runner uses GITEA_TOKEN)
|
||||
cred = Path.home() / ".git-credentials"
|
||||
if cred.exists():
|
||||
import re
|
||||
m = re.search(r"https://[^:]+:([^@]+)@", cred.read_text())
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_init = sub.add_parser("init", help="release initialization")
|
||||
p_init.add_argument("--version", required=True)
|
||||
p_init.add_argument("--server", required=True)
|
||||
p_init.add_argument("--repo", required=True)
|
||||
p_init.add_argument("--store", default="store/scaffold/current.json")
|
||||
p_init.add_argument("--release-sha", default="")
|
||||
p_init.add_argument("--deployed-sha", default="")
|
||||
|
||||
p_ref = sub.add_parser("refresh", help="convergence refresh")
|
||||
p_ref.add_argument("--version", required=True)
|
||||
p_ref.add_argument("--server", required=True)
|
||||
p_ref.add_argument("--repo", required=True)
|
||||
p_ref.add_argument("--store", default="store/scaffold/current.json")
|
||||
p_ref.add_argument("--release-sha", default="")
|
||||
p_ref.add_argument("--deployed-sha", required=True,
|
||||
help="converged deployed commit (from the deployment "
|
||||
"authority — required, never assumed)")
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
token = _token()
|
||||
if not token:
|
||||
print(json.dumps({"failed": True,
|
||||
"error": "no Gitea token available"}))
|
||||
return 1
|
||||
|
||||
if args.cmd == "init":
|
||||
note = rn.auto_initial_note(
|
||||
version=args.version,
|
||||
summary=f"Rune v{args.version} — evidence-derived release "
|
||||
f"history (automatic initialization)",
|
||||
scaffold_store=args.store,
|
||||
release_sha=args.release_sha,
|
||||
deployed_sha=args.deployed_sha or args.release_sha,
|
||||
)
|
||||
result = rn.publish_release(args.server, args.repo, token, note)
|
||||
print(json.dumps(result))
|
||||
# fail-closed: initialization failure must be visible so Rune cannot
|
||||
# silently create another empty historical release (§5/§16)
|
||||
return 0 if not result.get("failed") else 1
|
||||
|
||||
if args.cmd == "refresh":
|
||||
existing = rn.fetch_release(args.server, args.repo, token,
|
||||
f"v{args.version}")
|
||||
if existing is None:
|
||||
# no release yet for this version — init instead (convergence
|
||||
# may legitimately arrive before/without an init if the
|
||||
# auto-version run failed); idempotent recovery path
|
||||
note = rn.build_from_deployment(
|
||||
version=args.version, deployed_sha=args.deployed_sha,
|
||||
scaffold_store=args.store, release_sha=args.release_sha)
|
||||
else:
|
||||
note = rn.convergence_refresh(
|
||||
rn.ReleaseNote.from_dict({
|
||||
"version": args.version,
|
||||
"summary": _summary_from_body(existing.get("body", "")),
|
||||
"provenance": [],
|
||||
}),
|
||||
deployed_sha=args.deployed_sha, scaffold_store=args.store,
|
||||
extra_provenance="automatic convergence refresh (deploy "
|
||||
"workflow)")
|
||||
# preserve structural fields the stored body carries
|
||||
note.release_sha = _sha_from_body(existing.get("body", ""),
|
||||
"tag") or args.release_sha
|
||||
result = rn.publish_release(args.server, args.repo, token, note)
|
||||
print(json.dumps(result))
|
||||
# visible failure; retry converges (§10). Deployment validity is
|
||||
# NOT rolled back over documentation — the workflow marks this
|
||||
# step appropriately.
|
||||
return 0 if not result.get("failed") else 1
|
||||
return 2
|
||||
|
||||
|
||||
def _summary_from_body(body: str) -> str:
|
||||
"""Extract the summary paragraph (between the H1 and the first other
|
||||
heading/italic line) from a stored release body."""
|
||||
lines = body.splitlines()
|
||||
grab = []
|
||||
for ln in lines[1:]:
|
||||
s = ln.strip()
|
||||
if not s:
|
||||
if grab:
|
||||
break
|
||||
continue
|
||||
if s.startswith(("#", "_", "##", "-")):
|
||||
break
|
||||
grab.append(s)
|
||||
return " ".join(grab) or f"Rune — evidence-derived release history"
|
||||
|
||||
|
||||
def _sha_from_body(body: str, which: str) -> str:
|
||||
import re
|
||||
pat = ("tag commit: `([0-9a-f]+)`" if which == "tag"
|
||||
else r"\*\*final deployed: `([0-9a-f]+)`\*\*")
|
||||
m = re.search(pat, body, re.I)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.66.35"
|
||||
__version__ = "0.66.48"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
@@ -231,7 +231,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.66.35"' in rune_version,
|
||||
'__version__ = "0.66.48"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
"""construction_decision.py — live Need → Decision → Construction Contract.
|
||||
|
||||
SCAFFOLD-TRANCHE-004: the adoption layer that turns the Tranche 2/3
|
||||
mechanisms into a durable procedure Rune no longer has to remember.
|
||||
|
||||
NEED (references Task/Intent — never duplicates them)
|
||||
↓ normalize_need() missing info is NOT invented
|
||||
↓ discover + fit per authority (deterministic; Spine + Project Scaffold)
|
||||
↓ resolve per authority + merge (capability_fit resolution surface)
|
||||
↓ resolve_toolchain() (existing project toolchain wins)
|
||||
↓ layout obligations (LOCKED authority inherited)
|
||||
↓ approval gate (material changes → user)
|
||||
↓ DecisionTrace (durable, model-independent)
|
||||
↓ generate_contract() (populated from the trace)
|
||||
|
||||
Boundaries (brief §3, §32, §33):
|
||||
- NOT a task engine: Need carries a reference to the existing Task/Intent
|
||||
authority; Scaffolding never becomes the "why" authority.
|
||||
- NOT a workflow engine: one decision function, one trace object.
|
||||
- NOT a research pipeline: insufficient evidence routes to a recorded
|
||||
research path (seam) — the live ADAPT wiring is a recorded route, not
|
||||
an executed pipeline call (honest seam, §6).
|
||||
- NOT a structural authority: it reads Spine/Project Scaffold and
|
||||
WRITES only Construction Contracts + decision traces.
|
||||
- BUILD_NEW is never a fallback (§8): merge precedence and the
|
||||
insufficient-evidence invariant of capability_fit enforce it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune import capability_fit as cf
|
||||
from rune import toolchain as tc
|
||||
from rune.structural_store import relationships
|
||||
|
||||
__all__ = ["Need", "make_decision", "generate_contract", "merge_resolutions"]
|
||||
|
||||
|
||||
# ── Need intake (§4) ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Need:
|
||||
"""Construction-decision input. References — does not copy — the
|
||||
existing Task/Intent authority (`task_ref` is a pointer, not a copy).
|
||||
Carries no task state of its own: it is WHY-adjacent input to Scaffolding,
|
||||
not a backlog entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
need_id: str,
|
||||
objective: str,
|
||||
*,
|
||||
origin: str = "user",
|
||||
project: str = "rune",
|
||||
task_ref: str = "",
|
||||
constraints: dict | None = None,
|
||||
affected_surface: list[str] | None = None,
|
||||
evidence_refs: list[str] | None = None,
|
||||
approval_context: str = "",
|
||||
requested_permanence: str = "",
|
||||
):
|
||||
if not need_id:
|
||||
raise ValueError("need_id required")
|
||||
if not objective:
|
||||
raise ValueError("objective (required effect) required — do not normalize a need we cannot state")
|
||||
self.need_id = need_id
|
||||
self.objective = objective
|
||||
self.origin = origin
|
||||
self.project = project
|
||||
self.task_ref = task_ref
|
||||
self.constraints = dict(constraints or {})
|
||||
self.affected_surface = list(affected_surface or [])
|
||||
self.evidence_refs = list(evidence_refs or [])
|
||||
self.approval_context = approval_context
|
||||
self.requested_permanence = requested_permanence
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"need_id": self.need_id,
|
||||
"objective": self.objective,
|
||||
"origin": self.origin,
|
||||
"project": self.project,
|
||||
"task_ref": self.task_ref,
|
||||
"constraints": dict(self.constraints),
|
||||
"affected_surface": list(self.affected_surface),
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
"approval_context": self.approval_context,
|
||||
"requested_permanence": self.requested_permanence,
|
||||
}
|
||||
|
||||
|
||||
# ── requirement normalization (§5) ─────────────────────────────────────────
|
||||
|
||||
|
||||
def normalize_need(need: Need) -> tuple[cf.CapabilityRequirement, list[str]]:
|
||||
"""Derive the CapabilityRequirement WITHOUT inventing facts. Contracts
|
||||
may come from explicit constraints (`contracts` key) or from contract-
|
||||
shaped entries on the affected surface (`contract:x` ids). Everything
|
||||
not determinable lands in `missing` — the caller records it and the
|
||||
resolution degrades to INSUFFICIENT_EVIDENCE rather than guessing."""
|
||||
contracts = sorted(need.constraints.get("contracts", []))
|
||||
contracts += sorted(a for a in need.affected_surface if a.startswith("contract:"))
|
||||
missing = []
|
||||
if need.constraints.get("environment"):
|
||||
pass # carried through
|
||||
else:
|
||||
missing.append("environment")
|
||||
if not need.evidence_refs:
|
||||
missing.append("evidence_refs")
|
||||
req = cf.CapabilityRequirement(
|
||||
need.objective,
|
||||
contracts=contracts,
|
||||
environment=need.constraints.get("environment", {}),
|
||||
target_project=need.project,
|
||||
source_need=need.need_id,
|
||||
task_ref=need.task_ref,
|
||||
requested_permanence=need.requested_permanence,
|
||||
**{k: v for k, v in need.constraints.items() if k not in ("contracts", "environment")},
|
||||
)
|
||||
return req, missing
|
||||
|
||||
|
||||
# ── affected-surface classification + resolution (Tranche 5 §3-5) ──────────
|
||||
|
||||
_SURFACE_UI = "ui:"
|
||||
_SURFACE_REGION = "region:"
|
||||
_SURFACE_CONTRACT = "contract:"
|
||||
_SURFACE_MODULE = "module:"
|
||||
|
||||
|
||||
def classify_surface(surface: str) -> str:
|
||||
"""Classify one affected-surface entry (open-world; Tranche 5 §3).
|
||||
Kinds: structural_entity | source_path | workflow_config | ui_component |
|
||||
layout_region | unresolved. Never drops an entry — unresolved is a
|
||||
first-class kind."""
|
||||
if surface.startswith((_SURFACE_UI,)):
|
||||
return "ui_component"
|
||||
if surface.startswith((_SURFACE_REGION,)):
|
||||
return "layout_region"
|
||||
if surface.startswith(
|
||||
(
|
||||
_SURFACE_CONTRACT,
|
||||
_SURFACE_MODULE,
|
||||
_SURFACE_CAPABILITY,
|
||||
)
|
||||
):
|
||||
return "structural_entity"
|
||||
if surface.endswith(".yml") or surface.endswith(".yaml") or surface.endswith(".json") or surface.endswith(".toml"):
|
||||
return "workflow_config"
|
||||
if (
|
||||
surface.endswith(".py")
|
||||
or "/" in surface
|
||||
or surface.endswith(".md")
|
||||
or surface.endswith(".rs")
|
||||
or surface.endswith(".ts")
|
||||
):
|
||||
return "source_path"
|
||||
return "unresolved"
|
||||
|
||||
|
||||
_SURFACE_CAPABILITY = "capability:"
|
||||
|
||||
|
||||
def resolve_surface(surface: str, kind: str, authorities: dict[str, dict]) -> dict:
|
||||
"""Deterministically resolve one surface against the structural
|
||||
authorities (Tranche 5 §5). No Memory, no LLM archaeology: identity
|
||||
lookups and recorded path->module mappings only.
|
||||
|
||||
Returns {surface, kind, resolved: [...], unresolved_reason: ""}.
|
||||
resolved entries: {entity_id, authority, via} where via records HOW the
|
||||
mapping was established (identity | path_attribute | provides)."""
|
||||
out = {"surface": surface, "kind": kind, "resolved": [], "unresolved_reason": ""}
|
||||
if kind == "structural_entity":
|
||||
for name, doc in authorities.items():
|
||||
e = ss_entity(doc, surface)
|
||||
if e is not None:
|
||||
out["resolved"].append({"entity_id": surface, "authority": name, "via": "identity"})
|
||||
break
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "entity id not found in any authority"
|
||||
elif kind == "source_path":
|
||||
# path -> module via recorded identities.path in any authority
|
||||
for name, doc in authorities.items():
|
||||
for e in doc["entities"]:
|
||||
p = e.get("identities", {}).get("path", "")
|
||||
if p and (p == surface or p.endswith("/" + surface) or surface.endswith(p)):
|
||||
out["resolved"].append({"entity_id": e["id"], "authority": name, "via": "path_attribute"})
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "no authority records this path as an entity (path remains file-scope only)"
|
||||
elif kind == "ui_component":
|
||||
for name, doc in authorities.items():
|
||||
cid = surface if surface.startswith("ui:") else f"ui:{surface}"
|
||||
e = ss_entity(doc, f"layout:{cid}")
|
||||
if e is not None:
|
||||
out["resolved"].append({"entity_id": f"layout:{cid}", "authority": name, "via": "layout_contract"})
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "no Layout Contract bound for component"
|
||||
elif kind == "layout_region":
|
||||
for name, doc in authorities.items():
|
||||
e = ss_entity(doc, surface if surface.startswith("region:") else f"region:{surface}")
|
||||
if e is not None:
|
||||
out["resolved"].append({"entity_id": e["id"], "authority": name, "via": "identity"})
|
||||
if not out["resolved"]:
|
||||
out["unresolved_reason"] = "region not defined in any authority"
|
||||
else:
|
||||
out["unresolved_reason"] = f"kind {kind} has no deterministic resolver"
|
||||
return out
|
||||
|
||||
|
||||
def ss_entity(doc: dict, eid: str):
|
||||
from rune.structural_store import entity as _e
|
||||
|
||||
return _e(doc, eid)
|
||||
|
||||
|
||||
def implemented_contracts(doc: dict, entity_id: str) -> list[str]:
|
||||
"""Contracts an entity implements/exposes/provides (structural fact)."""
|
||||
out = []
|
||||
for r in relationships(doc, from_id=entity_id):
|
||||
if r["type"] in {"implements", "exposes", "provides", "serves"}:
|
||||
out.append(r["to"])
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def resolve_affected_surface(need: Need, authorities: dict[str, dict]) -> dict:
|
||||
"""Classify + resolve the WHOLE affected surface (Tranche 5 §3-5, §13).
|
||||
Produces the propagated representation the generated contract carries:
|
||||
affected_existing_entities structural ids (resolved)
|
||||
affected_paths file-scope entries (mechanical scope)
|
||||
affected_ui_surfaces ui components / regions found
|
||||
unresolved_surfaces retained, never dropped
|
||||
File scope != structural impact: a path stays a path for scope checking
|
||||
even when it also resolves to a module entity (no authority duplication)."""
|
||||
result = {
|
||||
"affected_existing_entities": [],
|
||||
"affected_paths": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"affected_regions": [],
|
||||
"workflow_config_surfaces": [],
|
||||
"unresolved_surfaces": [],
|
||||
"resolutions": [],
|
||||
}
|
||||
for surface in need.affected_surface:
|
||||
kind = classify_surface(surface)
|
||||
res = resolve_surface(surface, kind, authorities)
|
||||
result["resolutions"].append(res)
|
||||
if kind == "source_path":
|
||||
result["affected_paths"].append(surface)
|
||||
elif kind == "workflow_config":
|
||||
result["workflow_config_surfaces"].append(surface)
|
||||
elif kind == "ui_component":
|
||||
result["affected_ui_surfaces"].append(surface)
|
||||
elif kind == "layout_region":
|
||||
result["affected_regions"].append(surface)
|
||||
for r in res["resolved"]:
|
||||
eid = r["entity_id"]
|
||||
if eid not in result["affected_existing_entities"] and not eid.startswith("layout:"):
|
||||
result["affected_existing_entities"].append(eid)
|
||||
if res["unresolved_reason"] and not res["resolved"]:
|
||||
result["unresolved_surfaces"].append({"surface": surface, "kind": kind, "reason": res["unresolved_reason"]})
|
||||
for k in (
|
||||
"affected_existing_entities",
|
||||
"affected_paths",
|
||||
"affected_ui_surfaces",
|
||||
"affected_regions",
|
||||
"workflow_config_surfaces",
|
||||
):
|
||||
result[k] = sorted(set(result[k]))
|
||||
|
||||
# §9/§17 DERIVED EXPECTATION (structural consequence, not invention): a
|
||||
# Rune test path (tests/test_<name>.py) affected by the work implies the
|
||||
# "test:<name>" surface participates in the build. Derived only when not
|
||||
# already an existing entity nor explicitly declared; provenance records
|
||||
# the derivation for closure accounting.
|
||||
derived = []
|
||||
for surface in need.affected_surface:
|
||||
if surface.startswith("tests/test_") and surface.endswith(".py"):
|
||||
stem = surface[len("tests/test_") : -len(".py")]
|
||||
test_entity = f"test:{stem}"
|
||||
exists = any(ss_entity(doc, test_entity) is not None for doc in authorities.values())
|
||||
if test_entity not in need.constraints.get("expected_new_entities", []) and not exists:
|
||||
derived.append(
|
||||
{
|
||||
"entity": test_entity,
|
||||
"derived_from": surface,
|
||||
"rule": "Rune test-path convention: tests/test_<name>.py implies "
|
||||
"the test:<name> surface participates in the build",
|
||||
"evidence": f"affected_surface entry {surface}",
|
||||
}
|
||||
)
|
||||
result["derived_expected_new"] = derived
|
||||
return result
|
||||
|
||||
|
||||
# ── requirement completeness gate (Tranche 5 §6-9) ──────────────────────────
|
||||
|
||||
COMPLETE_ENOUGH = "COMPLETE_ENOUGH"
|
||||
MATERIAL_GAP = "MATERIAL_GAP"
|
||||
COMPLETENESS_UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
def check_completeness(
|
||||
req: cf.CapabilityRequirement, need: Need, authorities: dict[str, dict], surface_result: dict
|
||||
) -> dict:
|
||||
"""Bounded, structure-grounded completeness check run BEFORE fit.
|
||||
|
||||
Rule (deterministic, no model vibes): every affected-surface entry that
|
||||
resolves to a structural entity contributes that entity's IMPLEMENTED
|
||||
contracts to the requirement's material scope. If an affected entity
|
||||
implements contracts NOT represented in the normalized requirement, the
|
||||
requirement is materially incomplete — the need touches a component
|
||||
whose obligations it has not stated. That is exactly the deploy-incident
|
||||
shape: the affected promotion implementation carried an unrepresented
|
||||
ordering contract while the requirement named only qualification.
|
||||
|
||||
Verdicts (Tranche 5 §8):
|
||||
COMPLETE_ENOUGH all affected entities' contracts are represented
|
||||
(or none implement contracts) — fit may proceed
|
||||
MATERIAL_GAP uncovered implemented contracts exist on affected
|
||||
entities — named with provenance, routed to probe
|
||||
UNKNOWN no surface resolved — completeness undeterminable;
|
||||
never silently COMPLETE (§8)
|
||||
|
||||
Derived requirements (§9) are structural consequences, not inventions:
|
||||
each uncovered contract carries derived_from (the entity that
|
||||
implements it) + evidence (the implements relationship) + rule.
|
||||
"""
|
||||
gaps = []
|
||||
resolved_any = False
|
||||
for res in surface_result["resolutions"]:
|
||||
for r in res["resolved"]:
|
||||
resolved_any = True
|
||||
for name, doc in authorities.items():
|
||||
e = ss_entity(doc, r["entity_id"])
|
||||
if e is None:
|
||||
continue
|
||||
for contract in implemented_contracts(doc, r["entity_id"]):
|
||||
if contract not in req.contracts:
|
||||
gaps.append(
|
||||
{
|
||||
"missing_contract": contract,
|
||||
"derived_from": r["entity_id"],
|
||||
"surface": res["surface"],
|
||||
"authority": name,
|
||||
"rule": "affected-surface entity implements a "
|
||||
"contract not represented in the "
|
||||
"normalized requirement",
|
||||
"evidence": f"{r['entity_id']} implements {contract} (authority: {name})",
|
||||
}
|
||||
)
|
||||
break # entity found in one authority is enough
|
||||
if gaps:
|
||||
# dedupe by (missing_contract, derived_from)
|
||||
seen = set()
|
||||
unique = []
|
||||
for g in gaps:
|
||||
key = (g["missing_contract"], g["derived_from"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(g)
|
||||
return {
|
||||
"verdict": MATERIAL_GAP,
|
||||
"gaps": sorted(unique, key=lambda g: (g["missing_contract"], g["derived_from"])),
|
||||
"probe_questions": [
|
||||
f"does the need's stated failure implicate {g['derived_from']}'s {g['missing_contract']} obligation?"
|
||||
for g in unique[:5]
|
||||
],
|
||||
"note": "MATERIAL_GAP: affected surface carries obligations "
|
||||
"the requirement does not represent — refine before "
|
||||
"trusting an apparently-complete fit",
|
||||
}
|
||||
if resolved_any:
|
||||
return {
|
||||
"verdict": COMPLETE_ENOUGH,
|
||||
"gaps": [],
|
||||
"probe_questions": [],
|
||||
"note": "all resolved affected entities' implemented contracts are represented in the requirement",
|
||||
}
|
||||
return {
|
||||
"verdict": COMPLETENESS_UNKNOWN,
|
||||
"gaps": [],
|
||||
"probe_questions": [f"which structural entities does '{s}' map to?" for s in need.affected_surface[:5]],
|
||||
"note": "no affected surface resolved — completeness undeterminable; UNKNOWN never silently becomes COMPLETE",
|
||||
}
|
||||
|
||||
|
||||
def record_proposed_requirement(
|
||||
contract_id: str, *, provider: str, model: str, evidence: list[str], confidence: float
|
||||
) -> dict:
|
||||
"""Record a MODEL-PROPOSED requirement addition (Tranche 5 §12). It is
|
||||
evidence with a verification state — never structural authority by
|
||||
itself. It becomes accepted only via existing authority, research
|
||||
evidence, implementation evidence, or explicit user decision; until
|
||||
then normalize_need excludes it from the requirement's contract set."""
|
||||
if not provider or not model:
|
||||
raise ValueError("model-proposed requirement requires provider+model")
|
||||
if not 0.0 <= confidence <= 1.0:
|
||||
raise ValueError("confidence must be in [0,1]")
|
||||
return {
|
||||
"kind": "model_proposed_requirement",
|
||||
"contract": contract_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"evidence": sorted(evidence),
|
||||
"confidence": confidence,
|
||||
"verification_state": "PENDING",
|
||||
}
|
||||
|
||||
|
||||
# ── research routing seam (§6 — honest: recorded route, not execution) ─────
|
||||
|
||||
|
||||
def research_route(req: cf.CapabilityRequirement, decision: dict) -> dict:
|
||||
"""Record which evidence path the decision needs. This is a SEAM: the
|
||||
live ADAPT interface is not yet wired for direct invocation, so the
|
||||
route is recorded on the trace for the orchestrating session to act on.
|
||||
NOT claimed as executed research."""
|
||||
if decision["resolution"] == "INSUFFICIENT_EVIDENCE":
|
||||
return {
|
||||
"route": "TARGETED_EVIDENCE_PROBE",
|
||||
"question": f"what satisfies: {req.objective[:120]}",
|
||||
"status": "RECORDED_NOT_EXECUTED",
|
||||
}
|
||||
if decision["resolution"] == "ACQUIRE_EXTERNAL":
|
||||
return {
|
||||
"route": "FULL_RESEARCH_PIPELINE",
|
||||
"question": f"external acquisition options for: {req.objective[:120]}",
|
||||
"status": "RECORDED_NOT_EXECUTED",
|
||||
}
|
||||
return {"route": "NONE", "status": "LOCAL_SOLVE"}
|
||||
|
||||
|
||||
# ── resolution merge across authorities ─────────────────────────────────────
|
||||
|
||||
_PRECEDENCE = [
|
||||
"SATISFIED",
|
||||
"REUSE",
|
||||
"COMPOSE",
|
||||
"ADAPT_EXISTING",
|
||||
"CONFIGURE",
|
||||
"ACQUIRE_EXTERNAL",
|
||||
"INSUFFICIENT_EVIDENCE",
|
||||
"BUILD_NEW",
|
||||
]
|
||||
|
||||
|
||||
def merge_resolutions(results: list[dict]) -> dict:
|
||||
"""Merge per-authority resolution results. Precedence: a concrete
|
||||
reuse/compose/adapt verdict from any authority wins over
|
||||
INSUFFICIENT_EVIDENCE from another; BUILD_NEW can only result when NO
|
||||
authority produced a better verdict AND positive gap evidence exists —
|
||||
it is never a fallback across merges (§8)."""
|
||||
if not results:
|
||||
return {"resolution": "INSUFFICIENT_EVIDENCE", "rationale": "no authority consulted"}
|
||||
best = min(results, key=lambda r: _PRECEDENCE.index(r["resolution"]))
|
||||
merged = dict(best)
|
||||
merged["per_authority"] = [{"authority": r.get("authority", ""), "resolution": r["resolution"]} for r in results]
|
||||
if best["resolution"] == "BUILD_NEW" and len(results) > 1:
|
||||
# only honest if every authority independently agrees
|
||||
if any(r["resolution"] != "BUILD_NEW" for r in results):
|
||||
weaker = [r for r in results if r["resolution"] == "INSUFFICIENT_EVIDENCE"]
|
||||
if weaker:
|
||||
merged = dict(weaker[0])
|
||||
merged["per_authority"] = [
|
||||
{"authority": r.get("authority", ""), "resolution": r["resolution"]} for r in results
|
||||
]
|
||||
merged["rationale"] = (
|
||||
"BUILD_NEW rejected: at least one authority returned "
|
||||
"INSUFFICIENT_EVIDENCE — positive gap evidence required "
|
||||
"from all consulted authorities (§8)"
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
# ── user-approval gate (§13) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def needs_user_approval(
|
||||
resolution: str, toolchain_decision: tc.DecisionRecord, layout_obligations: list[dict], need: Need
|
||||
) -> dict:
|
||||
"""Classify whether decision→contract requires explicit user approval.
|
||||
Small bounded work inside an already-approved contract does NOT re-ask
|
||||
(need.approval_context covers it)."""
|
||||
reasons = []
|
||||
if toolchain_decision.user_approval_required:
|
||||
reasons.append("toolchain change (CHANGE_MAJOR)")
|
||||
if resolution == "BUILD_NEW" and need.requested_permanence != "temporary":
|
||||
reasons.append("new capability construction")
|
||||
if any(o["lock"] for o in layout_obligations):
|
||||
reasons.append("USER-LOCKED layout surface affected — placement changes require user authorization")
|
||||
if need.requested_permanence == "permanent":
|
||||
reasons.append("permanent promotion requested (user-controlled)")
|
||||
if need.constraints.get("new_repository") or need.constraints.get("new_application"):
|
||||
reasons.append("new repository/application")
|
||||
covered = bool(need.approval_context) and not reasons
|
||||
if reasons and need.approval_context and need.constraints.get("approval_covers") == "all":
|
||||
covered = True
|
||||
reasons = [f"{r} — covered by: {need.approval_context}" for r in reasons]
|
||||
return {
|
||||
"required": bool(reasons) and not covered,
|
||||
"reasons": reasons,
|
||||
"covered_by": need.approval_context if covered else "",
|
||||
}
|
||||
|
||||
|
||||
# ── the live decision path ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_decision(
|
||||
need: Need,
|
||||
*,
|
||||
authorities: dict[str, dict],
|
||||
toolchain_constraints: tc.ToolchainConstraints | None = None,
|
||||
project_toolchain: dict | None = None,
|
||||
prior_iterations: list[dict] | None = None,
|
||||
extra_patterns: list | None = None,
|
||||
) -> dict:
|
||||
"""Run NEED → decision trace. `authorities` maps authority name → store
|
||||
doc (e.g. {"rune_spine": spine_doc, "project_scaffold": scaffold_doc}).
|
||||
`project_toolchain` seeds the Toolchain Resolver's existing-project
|
||||
constraint (existing toolchain wins — §12).
|
||||
`prior_iterations` (Tranche 5 §11): earlier decision iterations being
|
||||
refined — retained in the trace with why-it-changed provenance; the
|
||||
final resolution stays singular.
|
||||
|
||||
Order of operations (Tranche 5 §6): affected-surface resolution →
|
||||
REQUIREMENT COMPLETENESS GATE → discovery/fit → merge. A MATERIAL_GAP
|
||||
downgrades premature concrete verdicts (REUSE/SATISFIED/COMPOSE/
|
||||
ADAPT_EXISTING) to INSUFFICIENT_EVIDENCE with the gap recorded — the
|
||||
fit engine was right GIVEN its requirement; the requirement was too
|
||||
narrow."""
|
||||
req, missing = normalize_need(need)
|
||||
trace: dict = {
|
||||
"need": need.to_dict(),
|
||||
"requirement": req.to_dict(),
|
||||
"normalization_missing": missing,
|
||||
"candidates": [],
|
||||
"fits": [],
|
||||
"per_authority_results": [],
|
||||
"layout_obligations": [],
|
||||
"research": {},
|
||||
"toolchain": {},
|
||||
"approval": {},
|
||||
"resolution": "",
|
||||
"rationale": "",
|
||||
"iterations": list(prior_iterations or []),
|
||||
}
|
||||
|
||||
# affected-surface classification + resolution (Tranche 5 §3-5)
|
||||
surface_result = resolve_affected_surface(need, authorities)
|
||||
trace["affected_surfaces"] = surface_result
|
||||
|
||||
# CONTEXTUAL CONSTRUCTION MODE (Tranche 10): typed mode classification
|
||||
# with evidence — ONE canonical home (construction_mode); §3 distinction
|
||||
# preserved: capability BUILD_NEW != mode PROJECT_BUILD
|
||||
from rune.construction_mode import classify_mode
|
||||
mode_decision = classify_mode(
|
||||
need.objective,
|
||||
target_project=need.project if need.project != "rune" else "rune",
|
||||
project_exists=(need.project != "rune"), # rune itself exists
|
||||
affected_surface=need.affected_surface)
|
||||
trace["construction_mode"] = mode_decision.to_dict()
|
||||
|
||||
# REQUIREMENT COMPLETENESS GATE (Tranche 5 §6-8) — before fit
|
||||
completeness = check_completeness(req, need, authorities, surface_result)
|
||||
trace["completeness"] = completeness
|
||||
|
||||
# live discovery + fit + resolution per authority (§7-8)
|
||||
results = []
|
||||
all_fits = []
|
||||
for name, doc in authorities.items():
|
||||
candidates = cf.discover_candidates(doc, req)
|
||||
fits = [cf.evaluate_fit(req, c) for c in candidates]
|
||||
for f, c in zip(fits, candidates):
|
||||
f["authority"] = name
|
||||
c["authority"] = name
|
||||
all_fits.extend(fits)
|
||||
trace["candidates"].extend(
|
||||
{k: c[k] for k in ("id", "kind", "maturity", "status", "authority", "provides", "consumers")}
|
||||
for c in candidates
|
||||
)
|
||||
res = cf.decide(doc, req, fits)
|
||||
res["authority"] = name
|
||||
results.append(res)
|
||||
trace["fits"] = all_fits
|
||||
if results:
|
||||
merged = merge_resolutions(results)
|
||||
else:
|
||||
merged = {"resolution": "INSUFFICIENT_EVIDENCE", "rationale": "no authorities provided"}
|
||||
if missing and merged["resolution"] in ("BUILD_NEW",):
|
||||
# normalization gaps block BUILD_NEW too (§5: do not invent)
|
||||
merged = {
|
||||
"resolution": "INSUFFICIENT_EVIDENCE",
|
||||
"rationale": f"requirement normalization incomplete: {missing}; refusing to build on guesses",
|
||||
}
|
||||
if completeness["verdict"] == MATERIAL_GAP and merged["resolution"] in (
|
||||
"SATISFIED",
|
||||
"REUSE",
|
||||
"COMPOSE",
|
||||
"ADAPT_EXISTING",
|
||||
"CONFIGURE",
|
||||
):
|
||||
# PREMATURE-FIT GUARD (Tranche 5 §6-7): the fit engine was correct
|
||||
# GIVEN its requirement — but the requirement is materially
|
||||
# incomplete (affected surface carries unrepresented obligations).
|
||||
# A concrete verdict on an incomplete requirement is not trustworthy.
|
||||
merged = {
|
||||
"resolution": "INSUFFICIENT_EVIDENCE",
|
||||
"rationale": (
|
||||
"premature fit blocked: requirement is materially incomplete "
|
||||
f"({len(completeness['gaps'])} unrepresented obligation(s) on "
|
||||
"affected surfaces); refine the requirement before trusting "
|
||||
"an apparently-complete fit"
|
||||
),
|
||||
"premature_fit_blocked": {
|
||||
"would_have_been": merged["resolution"],
|
||||
"gaps": completeness["gaps"],
|
||||
},
|
||||
}
|
||||
trace["resolution"] = merged["resolution"]
|
||||
trace["rationale"] = merged.get("rationale", "")
|
||||
if missing and trace["resolution"] == "INSUFFICIENT_EVIDENCE":
|
||||
trace["rationale"] += f"; normalization gaps recorded, not invented: {missing}"
|
||||
trace["per_authority_results"] = merged.get("per_authority", results)
|
||||
trace["_merged"] = merged
|
||||
|
||||
# toolchain (§12): existing project toolchain wins
|
||||
constraints = toolchain_constraints or tc.ToolchainConstraints(**(project_toolchain or {}))
|
||||
tdec = tc.resolve_toolchain(constraints)
|
||||
trace["toolchain"] = tdec.to_dict()
|
||||
|
||||
# layout obligations (§19-20): affected surface + candidate impls
|
||||
affected = list(need.affected_surface)
|
||||
for c in trace["candidates"]:
|
||||
affected.append(c["id"])
|
||||
lay = []
|
||||
for name, doc in authorities.items():
|
||||
lay.extend(layout_obligations_for(doc, affected))
|
||||
trace["layout_obligations"] = lay
|
||||
|
||||
# research routing (§6, honest seam)
|
||||
trace["research"] = research_route(req, merged)
|
||||
|
||||
# PATTERN SELECTION (Tranche 6 §4-7): evidence-backed applicability over
|
||||
# the builtin registry + caller-supplied patterns; obligations filtered
|
||||
# per project evidence; conflicts surfaced, never silently resolved.
|
||||
# Project evidence > pattern assumption (§3): a pattern that contradicts
|
||||
# recorded project exclusions becomes DOES_NOT_APPLY.
|
||||
from rune.construction_pattern import ( # noqa: E402 — local: pattern layer
|
||||
combine_pattern_obligations, derive_pattern_obligations,
|
||||
evaluate_applicability)
|
||||
from rune.construction_patterns_builtin import BUILTIN_PATTERNS # noqa: E402
|
||||
|
||||
pattern_candidates = list(BUILTIN_PATTERNS) + list(
|
||||
extra_patterns or [])
|
||||
patterns_considered = []
|
||||
derivations = []
|
||||
for pattern in pattern_candidates:
|
||||
app = evaluate_applicability(pattern, need, surface_result, authorities)
|
||||
patterns_considered.append(app)
|
||||
# APPLIES and PARTIAL both reach obligation derivation: applicability
|
||||
# gates whether the pattern is CONSULTED; the per-obligation `when`
|
||||
# conditions do the precision filtering (Tranche 6 §7). The
|
||||
# applicability verdict is recorded on every derived obligation so
|
||||
# weaker evidence stays visible.
|
||||
if app["verdict"] in ("APPLIES", "PARTIAL"):
|
||||
d = derive_pattern_obligations(
|
||||
pattern, need, surface_result, authorities,
|
||||
project_exceptions=need.constraints.get(
|
||||
"pattern_exceptions", []))
|
||||
for entry in d["accepted"] + d["rejected"] + d["unresolved"]:
|
||||
entry["pattern_applicability"] = app["verdict"]
|
||||
derivations.append(d)
|
||||
combined = combine_pattern_obligations(derivations) if derivations else {
|
||||
"obligations": [], "conflicts": []}
|
||||
trace["patterns_considered"] = patterns_considered
|
||||
trace["pattern_obligations"] = combined
|
||||
|
||||
# approval gate (§13)
|
||||
trace["approval"] = needs_user_approval(merged["resolution"], tdec, lay, need)
|
||||
trace["obligations"] = obligations_from_trace(trace, req)
|
||||
# pattern-derived obligations join the contract's verification set (§19):
|
||||
# provenance stays PATTERN (distinct from STRUCTURAL derivations in
|
||||
# obligations_from_trace — §8 provenance separation)
|
||||
trace["obligations"]["pattern"] = combined["obligations"]
|
||||
trace["obligations"]["pattern_conflicts"] = combined["conflicts"]
|
||||
for po in combined["obligations"]:
|
||||
trace["obligations"]["verification"].append(
|
||||
f"[pattern:{po['pattern']}@r{po['revision']}] {po['obligation']}")
|
||||
return trace
|
||||
|
||||
|
||||
def layout_obligations_for(doc: dict, affected: list[str]) -> list[dict]:
|
||||
"""Layout obligations from a store doc without a Scaffold instance
|
||||
(delegates to the same authority logic via a lightweight query)."""
|
||||
obligations = []
|
||||
affected_set = set(affected)
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "layout_contract" or e.get("status") == "RETIRED":
|
||||
continue
|
||||
ident = e["identities"]
|
||||
comp, region, impl = (ident.get("component", ""), ident.get("region", ""), ident.get("implementation", ""))
|
||||
hit = (
|
||||
comp in affected
|
||||
or f"region:{region}" in affected
|
||||
or (impl and any(a in impl for a in affected if a.endswith(".py")))
|
||||
)
|
||||
if not hit:
|
||||
continue
|
||||
locked = bool(ident.get("lock"))
|
||||
obligations.append(
|
||||
{
|
||||
"component": comp,
|
||||
"region": region,
|
||||
"lock": locked,
|
||||
"provenance": ident.get("provenance", "scaffold"),
|
||||
"obligation": (
|
||||
"USER-LOCKED: preserve user placement in region "
|
||||
"'%s'; changes require explicit user authorization" % region
|
||||
)
|
||||
if locked
|
||||
else (
|
||||
"preserve/verify %s placement in region '%s'; movable "
|
||||
"only via authorized layout amendment" % (comp, region)
|
||||
),
|
||||
"verification": ("layout_contract_check must PASS; USER-LOCKED placement unchanged")
|
||||
if locked
|
||||
else "layout_contract_check must PASS",
|
||||
"status": e.get("status", "ACTIVE"),
|
||||
}
|
||||
)
|
||||
return obligations
|
||||
|
||||
|
||||
def obligations_from_trace(trace: dict, req: cf.CapabilityRequirement) -> dict:
|
||||
"""BUILD-BUT-DON'T-BREAK (§9) + composition/acquire obligations (§10-11)
|
||||
derived automatically from the trace — the builder does not memorize the
|
||||
rule; the path produces it."""
|
||||
resolution = trace["resolution"]
|
||||
obligations: dict = {
|
||||
"new_behavior": [],
|
||||
"regression": [],
|
||||
"composition": [],
|
||||
"acquisition": [],
|
||||
"layout": trace["layout_obligations"],
|
||||
"verification": [],
|
||||
}
|
||||
merged = trace.get("_merged", {})
|
||||
selected = merged.get("selected") or merged.get("components") or []
|
||||
if isinstance(selected, str):
|
||||
selected = [selected]
|
||||
if resolution in ("ADAPT_EXISTING", "REUSE", "SATISFIED") and selected:
|
||||
# regression obligations from existing consumers + contracts (§9)
|
||||
for c in trace["candidates"]:
|
||||
if c["id"] in selected and c.get("consumers"):
|
||||
obligations["regression"].append(
|
||||
f"preserve existing behavior of {c['id']} for consumers: {', '.join(c['consumers'])}"
|
||||
)
|
||||
obligations["verification"].append("new behavior proven by tests before closure")
|
||||
if resolution == "ADAPT_EXISTING":
|
||||
obligations["verification"].append("regression proof: original capability's consumers still work")
|
||||
if resolution == "COMPOSE":
|
||||
obligations["composition"] = [
|
||||
{
|
||||
"components": merged.get("components", selected),
|
||||
"joining_contracts": req.contracts,
|
||||
"permanence": merged.get("permanence", "temporary (user-controlled)"),
|
||||
}
|
||||
]
|
||||
obligations["verification"].append("composition boundary behavior verified end-to-end")
|
||||
if resolution == "ACQUIRE_EXTERNAL":
|
||||
obligations["acquisition"] = merged.get("external", [])
|
||||
obligations["verification"].append("external acquisition requires research/approval step before install")
|
||||
if resolution == "BUILD_NEW":
|
||||
obligations["verification"].append("positive gap evidence recorded in contract")
|
||||
for lo in trace["layout_obligations"]:
|
||||
obligations["verification"].append(lo["verification"])
|
||||
return obligations
|
||||
|
||||
|
||||
# ── contract generation (§15) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_contract(
|
||||
s, doc: dict, trace: dict, *, contract_id: str, scope: list[str] | None = None, proposer_note: str = ""
|
||||
) -> dict:
|
||||
"""Populate a Construction Contract from the decision trace (Tranche 5
|
||||
§13-17 IMPACT-AWARE generation). The Need's affected surface is never
|
||||
lost — it is classified and propagated into distinct surfaces:
|
||||
|
||||
affected_entities structural entity ids (existing)
|
||||
expected_new_entities planned new structural entities
|
||||
ext.affected_paths file-scope entries (mechanical scope check)
|
||||
ext.affected_ui_surfaces ui components (layout obligations follow)
|
||||
ext.unresolved_surfaces retained, never dropped
|
||||
ext.expected_runtime_effects operational effects (deployment:current
|
||||
advances etc.) — accounting metadata, NOT a
|
||||
workflow engine; closure requires independent
|
||||
observation before marking them observed.
|
||||
|
||||
File scope: generated from affected paths + selected capability
|
||||
implementation paths + explicit expected-new paths — NOT unrestricted
|
||||
repo scope. `scope` param may still extend it; generated paths are
|
||||
recorded as such (provenance: affected-surface).
|
||||
"""
|
||||
merged = trace["_merged"]
|
||||
req = trace["requirement"]
|
||||
surfaces = trace.get("affected_surfaces", {})
|
||||
selected = merged.get("selected") or []
|
||||
if isinstance(selected, str):
|
||||
selected = [selected]
|
||||
|
||||
# structural affected: selected candidates + requirement contracts +
|
||||
# resolved surface entities — the union the old generator partially lost
|
||||
affected = {c["id"] for c in trace["candidates"] if c["id"] in selected}
|
||||
affected |= set(trace["requirement"].get("contracts", []))
|
||||
affected |= set(surfaces.get("affected_existing_entities", []))
|
||||
|
||||
# file scope: generated (affected paths + selected impl paths + expected
|
||||
# new paths) UNION caller-supplied additions, provenance-tracked
|
||||
affected_paths = list(surfaces.get("affected_paths", []))
|
||||
impl_paths = []
|
||||
for c in trace["candidates"]:
|
||||
if c["id"] in selected:
|
||||
p = c.get("identities", {}).get("path", "")
|
||||
if p:
|
||||
impl_paths.append(p)
|
||||
expected_new_paths = trace["need"]["constraints"].get("expected_new_source_paths", [])
|
||||
generated_scope = sorted(
|
||||
set(affected_paths)
|
||||
| set(surfaces.get("workflow_config_surfaces", []))
|
||||
| set(impl_paths)
|
||||
| set(expected_new_paths)
|
||||
)
|
||||
caller_scope = sorted(set(scope or []) - set(generated_scope))
|
||||
final_scope = sorted(set(generated_scope) | set(caller_scope))
|
||||
|
||||
expected_runtime = sorted(trace["need"]["constraints"].get("expected_runtime_effects", []))
|
||||
|
||||
obl = trace["obligations"]
|
||||
s.new_contract(
|
||||
doc,
|
||||
contract_id=contract_id,
|
||||
need=trace["need"]["objective"],
|
||||
origin=trace["need"]["origin"],
|
||||
scope=final_scope,
|
||||
requirement=req.get("objective", ""),
|
||||
gap_evidence=merged.get("rationale", ""),
|
||||
research_refs=(
|
||||
trace["need"]["evidence_refs"]
|
||||
+ ([trace["research"]["route"]] if trace["research"]["route"] != "NONE" else [])
|
||||
),
|
||||
resolution=trace["resolution"],
|
||||
toolchain=trace["toolchain"],
|
||||
affected_entities=sorted(affected),
|
||||
expected_new_entities=sorted(set(
|
||||
trace["need"]["constraints"].get("expected_new_entities", [])
|
||||
) | {d["entity"] for d in surfaces.get("derived_expected_new", [])}),
|
||||
expected_consumers=[],
|
||||
regression_obligations=obl["regression"],
|
||||
verification_obligations=sorted(set(obl["verification"])),
|
||||
rollback_ref=trace["need"]["constraints"].get("rollback_ref", "git revert"),
|
||||
acceptance=[f"resolution {trace['resolution']} verified end-to-end"],
|
||||
non_goals=trace["need"]["constraints"].get("non_goals", []),
|
||||
)
|
||||
e = s.contract(doc, contract_id)
|
||||
e.setdefault("ext", {}).update(
|
||||
{
|
||||
"decision_trace": {
|
||||
k: trace[k]
|
||||
for k in (
|
||||
"need",
|
||||
"requirement",
|
||||
"candidates",
|
||||
"fits",
|
||||
"resolution",
|
||||
"rationale",
|
||||
"research",
|
||||
"toolchain",
|
||||
"approval",
|
||||
"obligations",
|
||||
"layout_obligations",
|
||||
"normalization_missing",
|
||||
"completeness",
|
||||
"affected_surfaces",
|
||||
"iterations",
|
||||
"patterns_considered",
|
||||
"pattern_obligations",
|
||||
"construction_mode",
|
||||
)
|
||||
},
|
||||
# distinct impact surfaces (Tranche 5 §13) — file scope != structural
|
||||
# impact != expected new != runtime effect
|
||||
"affected_paths": affected_paths,
|
||||
"affected_ui_surfaces": surfaces.get("affected_ui_surfaces", []),
|
||||
"affected_regions": surfaces.get("affected_regions", []),
|
||||
"workflow_config_surfaces": surfaces.get("workflow_config_surfaces", []),
|
||||
"unresolved_surfaces": surfaces.get("unresolved_surfaces", []),
|
||||
"expected_runtime_effects": expected_runtime,
|
||||
"scope_provenance": {"generated_from_affected_surface": generated_scope, "caller_added": caller_scope},
|
||||
}
|
||||
)
|
||||
if proposer_note:
|
||||
e["ext"]["decision_trace"]["proposer_note"] = proposer_note
|
||||
return e
|
||||
@@ -0,0 +1,838 @@
|
||||
"""construction_interface.py — the stable builder-facing Construction
|
||||
Interface (SCAFFOLD-TRANCHE-007).
|
||||
|
||||
ONE shared boundary for every builder — MiMo, Kimi, GLM, future models,
|
||||
deterministic scripts — and for both modes:
|
||||
|
||||
Rune-internal mode authority = Rune Spine + Rune's Project Scaffold
|
||||
external-project mode authority = that project's Project Scaffold
|
||||
|
||||
The builder must NOT need to know construction_decision.py, scaffold.py,
|
||||
structural_store.py, Layout Contract internals, or store paths. Those are
|
||||
implementation details behind this adapter.
|
||||
|
||||
Authority enforcement (§14) — the adapter is NOT a bypass:
|
||||
- builders may not rewrite Rune Spine or Project Scaffold truth
|
||||
- builders may not amend USER-LOCKED layout
|
||||
- builders may not silently broaden scope
|
||||
- builders may not self-certify completion ("done=true" is not a thing)
|
||||
- closing remains governed by Scaffolding (phase map + gates)
|
||||
- builder identity is RECORDED for audit but never changes semantics
|
||||
(§17): identical structured input → identical behavior, any builder.
|
||||
|
||||
Machine-readable output (§12): every operation returns a stable envelope —
|
||||
{schema, operation, success, ids, state, evidence, errors, warnings}
|
||||
Errors are explicit states (§13): NEED_INCOMPLETE, INSUFFICIENT_EVIDENCE,
|
||||
APPROVAL_REQUIRED, UNKNOWN_CONTRACT, SCOPE_VIOLATION, VERIFICATION_FAILED,
|
||||
STORE_WRITE_FAILED, STALE_STATE — never prose-only exit-1.
|
||||
|
||||
Transport (§3): typed Python API (this module) + JSON CLI (module main,
|
||||
same convention as rune-spine). No network service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rune import structural_store as ss
|
||||
from rune import scaffold as sc
|
||||
from rune import spine as spine_mod
|
||||
from rune import construction_decision as cd
|
||||
|
||||
__all__ = ["ConstructionInterface", "INTERFACE_SCHEMA_VERSION", "Errors"]
|
||||
|
||||
INTERFACE_SCHEMA_VERSION = 1
|
||||
|
||||
# ── explicit error states (§13) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class Errors:
|
||||
NEED_INCOMPLETE = "NEED_INCOMPLETE"
|
||||
INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE"
|
||||
APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
|
||||
UNKNOWN_CONTRACT = "UNKNOWN_CONTRACT"
|
||||
SCOPE_VIOLATION = "SCOPE_VIOLATION"
|
||||
VERIFICATION_FAILED = "VERIFICATION_FAILED"
|
||||
LAYOUT_VIOLATION = "LAYOUT_VIOLATION"
|
||||
PATTERN_CONFLICT = "PATTERN_CONFLICT"
|
||||
STORE_WRITE_FAILED = "STORE_WRITE_FAILED"
|
||||
STALE_STATE = "STALE_STATE"
|
||||
|
||||
|
||||
def _envelope(
|
||||
operation: str,
|
||||
*,
|
||||
success: bool,
|
||||
ids: dict | None = None,
|
||||
state: dict | None = None,
|
||||
evidence: list | None = None,
|
||||
errors: list | None = None,
|
||||
warnings: list | None = None,
|
||||
**extra,
|
||||
) -> dict:
|
||||
env = {
|
||||
"schema": INTERFACE_SCHEMA_VERSION,
|
||||
"operation": operation,
|
||||
"success": success,
|
||||
"ids": ids or {},
|
||||
"state": state or {},
|
||||
"evidence": evidence or [],
|
||||
"errors": errors or [],
|
||||
"warnings": warnings or [],
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
class ConstructionInterface:
|
||||
"""The builder-facing construction lifecycle for ONE project target.
|
||||
|
||||
Rune-internal mode (default):
|
||||
ConstructionInterface(builder="mimo")
|
||||
External-project mode:
|
||||
ConstructionInterface(builder="mimo",
|
||||
project_store="/path/to/.rune/scaffold",
|
||||
project_id="that-project")
|
||||
|
||||
`builder` is recorded for audit (session record, §20) and NEVER changes
|
||||
construction semantics (§17).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
builder: str = "unknown",
|
||||
*,
|
||||
project_store: str | None = None,
|
||||
project_id: str | None = None,
|
||||
spine_store: str = "store/spine/current.json",
|
||||
):
|
||||
if not builder:
|
||||
raise ValueError("builder identity required (recorded for audit)")
|
||||
self.builder = builder
|
||||
self.spine_store = spine_store
|
||||
if project_store:
|
||||
self.scaffold = sc.Scaffold(project_store, project_id or Path(project_store).name)
|
||||
self.mode = "external_project"
|
||||
else:
|
||||
self.scaffold = sc.Scaffold("store/scaffold", "issue161-harness")
|
||||
self.mode = "rune_internal"
|
||||
|
||||
# -- authority access (read-only projections for builders) ----------------
|
||||
|
||||
def _authorities(self) -> dict[str, dict]:
|
||||
"""The structural authorities for THIS mode. Rune-internal consults
|
||||
the real Spine store; external mode consults only the project
|
||||
scaffold (a Rune-independent project has no Rune Spine)."""
|
||||
auth: dict[str, dict] = {"project_scaffold": self.scaffold.load()}
|
||||
if self.mode == "rune_internal":
|
||||
sd = spine_mod.load(Path(self.spine_store))
|
||||
if sd is not None:
|
||||
auth["rune_spine"] = sd
|
||||
return auth
|
||||
|
||||
def _record_session(self, doc: dict, operation: str, detail: str) -> None:
|
||||
"""Builder session record (§20): compact, no transcripts."""
|
||||
doc.setdefault("observations", []).append(
|
||||
{
|
||||
"kind": "builder_session",
|
||||
"builder": self.builder,
|
||||
"mode": self.mode,
|
||||
"operation": operation,
|
||||
"detail": detail[:200],
|
||||
"observed_at": ss.now(),
|
||||
}
|
||||
)
|
||||
|
||||
# ── §5 submit_need ────────────────────────────────────────────────────────
|
||||
|
||||
def classify(self, objective: str, *, target_project: str | None = None,
|
||||
project_exists: bool | None = None,
|
||||
explicit_override: str | None = None,
|
||||
override_authority: str | None = None) -> dict:
|
||||
"""§11/§15: contextual construction-mode classification through the
|
||||
ONE canonical classifier. Builders (MiMo etc.) and Rune's native
|
||||
backend get identical semantics. UNKNOWN comes back with probe
|
||||
questions, never a silent choice."""
|
||||
from rune.construction_mode import classify_mode
|
||||
d = classify_mode(
|
||||
objective, target_project=target_project,
|
||||
project_exists=project_exists,
|
||||
explicit_override=explicit_override,
|
||||
override_authority=override_authority or self.builder)
|
||||
env = _envelope("classify", success=d.mode != "UNKNOWN",
|
||||
state={**d.to_dict(), "builder": self.builder})
|
||||
return env
|
||||
|
||||
def construction_context(self, contract_id: str | None = None) -> dict:
|
||||
"""§15 SHARED CONSTRUCTION CONTEXT: typed projection derived from
|
||||
canonical authorities (contract store, decision trace, verification
|
||||
state). Serves Rune TUI + Construction Interface without duplicating
|
||||
state machines. NOT a second authority."""
|
||||
doc = self.scaffold.load()
|
||||
ctx: dict = {"mode": None, "project": self.scaffold.project_id,
|
||||
"builder": self.builder}
|
||||
contracts = [e for e in doc["entities"]
|
||||
if e["kind"] == "construction_contract"]
|
||||
target = None
|
||||
if contract_id:
|
||||
target = self.scaffold.contract(doc, contract_id)
|
||||
else:
|
||||
# most recent non-closed contract = the active construction
|
||||
open_contracts = [e for e in contracts
|
||||
if e.get("status") == "open"]
|
||||
target = (sorted(open_contracts,
|
||||
key=lambda e: e.get("observed_at", ""))[-1]
|
||||
if open_contracts else None)
|
||||
if target is None:
|
||||
ctx.update({"active": False,
|
||||
"note": "no active construction"})
|
||||
return _envelope("construction_context", success=True,
|
||||
state=ctx)
|
||||
t = target.get("ext", {}).get("decision_trace", {})
|
||||
ctx.update({
|
||||
"active": True,
|
||||
"contract_id": target["id"].split(":", 1)[1],
|
||||
"need": t.get("need", {}).get("objective", "")[:200],
|
||||
"resolution": t.get("resolution", ""),
|
||||
"construction_mode": t.get("construction_mode", {}).get("mode"),
|
||||
"phase": target.get("phase"), "status": target.get("status"),
|
||||
"scope": target["identities"].get("scope", []),
|
||||
"obligations": _obligations_projection(target),
|
||||
"patterns": target.get("ext", {}).get("pattern_refs", []),
|
||||
"approval": t.get("approval", {}),
|
||||
"completeness": t.get("completeness", {}).get("verdict"),
|
||||
"unresolved": target.get("ext", {}).get("unresolved_surfaces", []),
|
||||
"next_action": target["identities"].get("next_action", ""),
|
||||
})
|
||||
return _envelope("construction_context", success=True, state=ctx)
|
||||
|
||||
def capabilities(self) -> dict:
|
||||
"""§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."""
|
||||
return _envelope(
|
||||
"capabilities",
|
||||
success=True,
|
||||
state={
|
||||
"schema_version": INTERFACE_SCHEMA_VERSION,
|
||||
"mode": self.mode,
|
||||
"project": self.scaffold.project_id,
|
||||
"operations": [
|
||||
"submit_need",
|
||||
"inspect_decision",
|
||||
"get_scope",
|
||||
"get_obligations",
|
||||
"report_observation",
|
||||
"verify",
|
||||
"transition",
|
||||
"close",
|
||||
"resume",
|
||||
"amend_scope",
|
||||
"capabilities",
|
||||
],
|
||||
"error_states": sorted(v for k, v in vars(Errors).items() if not k.startswith("_")),
|
||||
},
|
||||
)
|
||||
|
||||
def submit_need(
|
||||
self,
|
||||
objective: str,
|
||||
*,
|
||||
origin: str = "builder",
|
||||
constraints: dict | None = None,
|
||||
affected_surface: list[str] | None = None,
|
||||
evidence_refs: list[str] | None = None,
|
||||
approval_context: str = "",
|
||||
task_ref: str = "",
|
||||
generate: bool = True,
|
||||
contract_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Submit a structured Need; run the full live decision path;
|
||||
optionally generate the Construction Contract. Returns decision
|
||||
summary + contract ref + outstanding questions. Does NOT decide
|
||||
approval — that surfaces via state.approval."""
|
||||
if not objective or not objective.strip():
|
||||
return _envelope(
|
||||
"submit_need",
|
||||
success=False,
|
||||
errors=[{"error": Errors.NEED_INCOMPLETE, "detail": "objective is required"}],
|
||||
)
|
||||
need = cd.Need(
|
||||
need_id=f"need-{ss.now()}-{self.builder}",
|
||||
objective=objective,
|
||||
origin=origin,
|
||||
project=self.scaffold.project_id,
|
||||
task_ref=task_ref,
|
||||
constraints=dict(constraints or {}),
|
||||
affected_surface=list(affected_surface or []),
|
||||
evidence_refs=list(evidence_refs or []),
|
||||
approval_context=approval_context,
|
||||
)
|
||||
doc = self.scaffold.load()
|
||||
authorities = self._authorities()
|
||||
trace = cd.make_decision(need, authorities=authorities, project_toolchain={"existing_language": "python"})
|
||||
ids: dict = {"need_id": need.need_id}
|
||||
state: dict = {
|
||||
"resolution": trace["resolution"],
|
||||
"completeness": trace["completeness"]["verdict"],
|
||||
"research": trace["research"],
|
||||
"approval": trace["approval"],
|
||||
"builder": self.builder,
|
||||
}
|
||||
errors, warnings = [], []
|
||||
if trace["completeness"]["verdict"] == cd.MATERIAL_GAP:
|
||||
errors.append(
|
||||
{
|
||||
"error": Errors.INSUFFICIENT_EVIDENCE,
|
||||
"detail": "material requirement gap",
|
||||
"gaps": trace["completeness"]["gaps"],
|
||||
"probe_questions": trace["completeness"]["probe_questions"],
|
||||
}
|
||||
)
|
||||
if trace["obligations"].get("pattern_conflicts"):
|
||||
errors.append(
|
||||
{
|
||||
"error": Errors.PATTERN_CONFLICT,
|
||||
"detail": "pattern obligations conflict",
|
||||
"conflicts": trace["obligations"]["pattern_conflicts"],
|
||||
}
|
||||
)
|
||||
if trace["approval"]["required"]:
|
||||
errors.append({"error": Errors.APPROVAL_REQUIRED, "reasons": trace["approval"]["reasons"]})
|
||||
if trace["resolution"] == "INSUFFICIENT_EVIDENCE" and not errors:
|
||||
errors.append({"error": Errors.INSUFFICIENT_EVIDENCE, "detail": trace["rationale"][:200]})
|
||||
contract = None
|
||||
if generate and trace["resolution"] not in ("INSUFFICIENT_EVIDENCE",):
|
||||
cid = contract_id or f"BUILDER-{ss.now()}-{self.builder}"
|
||||
cd.generate_contract(
|
||||
self.scaffold,
|
||||
doc,
|
||||
trace,
|
||||
contract_id=cid,
|
||||
scope=None,
|
||||
proposer_note=f"via Construction Interface by {self.builder}",
|
||||
)
|
||||
ids["contract_id"] = cid
|
||||
state["contract_phase"] = "draft"
|
||||
state["next_action"] = "review for approval"
|
||||
warnings.append({"warning": "contract generated in draft; requires approval transition before building"})
|
||||
self._record_session(doc, "submit_need", f"{need.need_id} → {trace['resolution']}")
|
||||
try:
|
||||
self.scaffold.save(doc)
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"submit_need",
|
||||
success=False,
|
||||
ids=ids,
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
return _envelope("submit_need", success=not errors, ids=ids, state=state, errors=errors, warnings=warnings)
|
||||
|
||||
# ── §6 inspect_decision ───────────────────────────────────────────────────
|
||||
|
||||
def inspect_decision(self, contract_id: str) -> dict:
|
||||
"""Read-only projection of the durable decision trace (§6)."""
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"inspect_decision",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
t = c.get("ext", {}).get("decision_trace", {})
|
||||
return _envelope(
|
||||
"inspect_decision",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id, "need_id": t.get("need", {}).get("need_id", "")},
|
||||
state={
|
||||
"requirement": t.get("requirement", {}),
|
||||
"completeness": t.get("completeness", {}),
|
||||
"candidates": t.get("candidates", []),
|
||||
"fits": [f for f in t.get("fits", []) if f["fit"] != "UNKNOWN"],
|
||||
"resolution": t.get("resolution", ""),
|
||||
"rationale": t.get("rationale", ""),
|
||||
"toolchain": t.get("toolchain", {}),
|
||||
"patterns_considered": t.get("patterns_considered", []),
|
||||
"structural_obligations": {
|
||||
k: t.get("obligations", {}).get(k, [])
|
||||
for k in ("regression", "verification", "composition", "acquisition")
|
||||
},
|
||||
"pattern_obligations": t.get("obligations", {}).get("pattern", []),
|
||||
"approval": t.get("approval", {}),
|
||||
"iterations": t.get("iterations", []),
|
||||
"builder": self.builder,
|
||||
},
|
||||
)
|
||||
|
||||
# ── §7 get_scope ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_scope(self, contract_id: str) -> dict:
|
||||
"""Mechanical builder-facing scope (§7): what may I touch?"""
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"get_scope",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
ext = c.get("ext", {})
|
||||
return _envelope(
|
||||
"get_scope",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={
|
||||
"allowed_paths": c["identities"]["scope"],
|
||||
"affected_structural_entities": c["identities"]["affected_entities"],
|
||||
"expected_new_entities": c["identities"].get("expected_new_entities", []),
|
||||
"affected_paths": ext.get("affected_paths", []),
|
||||
"affected_ui_surfaces": ext.get("affected_ui_surfaces", []),
|
||||
"workflow_config_surfaces": ext.get("workflow_config_surfaces", []),
|
||||
"unresolved_surfaces": ext.get("unresolved_surfaces", []),
|
||||
"expected_runtime_effects": ext.get("expected_runtime_effects", []),
|
||||
"layout_obligations": t_layout(ext),
|
||||
"scope_amendments": ext.get("scope_amendments", []),
|
||||
"builder": self.builder,
|
||||
},
|
||||
)
|
||||
|
||||
def get_obligations(self, contract_id: str) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"get_obligations",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
return _envelope(
|
||||
"get_obligations",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={"obligations": _obligations_projection(c), "builder": self.builder},
|
||||
)
|
||||
|
||||
def report_observation(self, contract_id: str, observations: Any) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
if not isinstance(observations, list) or not observations:
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.NEED_INCOMPLETE, "detail": "observations list required"}],
|
||||
)
|
||||
items = []
|
||||
for o in observations:
|
||||
if not isinstance(o, dict) or not o.get("entity"):
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.NEED_INCOMPLETE, "detail": "each observation needs an entity"}],
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"entity": o["entity"],
|
||||
"source": o.get("source", f"builder:{self.builder}"),
|
||||
"detail": str(o.get("detail", ""))[:300],
|
||||
}
|
||||
)
|
||||
ss.record_observed_impact(doc, contract_id, items)
|
||||
self._record_session(doc, "report_observation", f"{len(items)} observation(s) on {contract_id}")
|
||||
try:
|
||||
self.scaffold.save(doc)
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
return _envelope(
|
||||
"report_observation",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={
|
||||
"recorded": len(items),
|
||||
"note": "evidence recorded; satisfaction is determined by Scaffolding gates, not builder claims",
|
||||
},
|
||||
)
|
||||
|
||||
def verify(
|
||||
self, contract_id: str, *, changed_files: list[str] | None = None, layout_observations: dict | None = None
|
||||
) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"verify",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
state: dict = {"phase": c.get("phase"), "status": c.get("status")}
|
||||
errors, warnings = [], []
|
||||
if changed_files is not None:
|
||||
scope = self.scaffold.check_scope(doc, contract_id, changed_files)
|
||||
state["scope_check"] = scope
|
||||
if scope["verdict"] == "red":
|
||||
errors.append({"error": Errors.SCOPE_VIOLATION, "out_of_scope": scope["out_of_scope"]})
|
||||
elif scope["verdict"] == "unknown":
|
||||
warnings.append({"warning": "scope UNKNOWN — contract has no approved scope paths"})
|
||||
if layout_observations:
|
||||
from rune import layout_contract as lc
|
||||
|
||||
gate = lc.check_layout(doc, layout_observations)
|
||||
state["layout_gate"] = gate["verdict"]
|
||||
if gate["verdict"] == "FAIL":
|
||||
errors.append(
|
||||
{"error": Errors.LAYOUT_VIOLATION, "checks": [x for x in gate["checks"] if x["verdict"] == "FAIL"]}
|
||||
)
|
||||
elif gate["verdict"] == "UNKNOWN":
|
||||
warnings.append({"warning": "layout UNKNOWN — never PASS"})
|
||||
obligations = _obligations_projection(c)
|
||||
q = c.get("ext", {}).get("contract_quality")
|
||||
if q:
|
||||
state["contract_quality"] = {
|
||||
k: q[k]
|
||||
for k in (
|
||||
"correctly_predicted",
|
||||
"expected_new_observed",
|
||||
"expected_runtime_observed",
|
||||
"genuinely_unexpected_new",
|
||||
"scope_check",
|
||||
)
|
||||
if k in q
|
||||
}
|
||||
state["obligations_total"] = len(obligations)
|
||||
state["remaining"] = (
|
||||
"closure requires phase transitions through verifying with evidence; builder cannot self-certify"
|
||||
)
|
||||
return _envelope(
|
||||
"verify",
|
||||
success=not errors,
|
||||
ids={"contract_id": contract_id},
|
||||
state=state,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def transition(self, contract_id: str, phase: str, *, evidence_ref: str = "") -> dict:
|
||||
doc = self.scaffold.load()
|
||||
try:
|
||||
self.scaffold.transition_contract(
|
||||
doc, contract_id, phase, evidence_ref=evidence_ref or f"builder:{self.builder}"
|
||||
)
|
||||
except sc.IllegalPhaseTransition as e:
|
||||
return _envelope(
|
||||
"transition",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.VERIFICATION_FAILED, "detail": str(e)}],
|
||||
)
|
||||
except ValueError as e:
|
||||
return _envelope(
|
||||
"transition",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": str(e)}],
|
||||
)
|
||||
self._record_session(doc, "transition", f"{contract_id} → {phase}")
|
||||
try:
|
||||
self.scaffold.save(doc)
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"transition",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
return _envelope("transition", success=True, ids={"contract_id": contract_id}, state={"phase": phase})
|
||||
|
||||
def close(self, contract_id: str, *, observed_evidence: list[dict], scope_files: list[str] | None = None) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
try:
|
||||
self.scaffold.close_contract(doc, contract_id, observed_evidence=observed_evidence, scope_files=scope_files)
|
||||
except sc.IllegalPhaseTransition as e:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.VERIFICATION_FAILED, "detail": str(e)}],
|
||||
)
|
||||
except ValueError as e:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": str(e)}],
|
||||
)
|
||||
self._record_session(doc, "close", f"{contract_id} closed")
|
||||
try:
|
||||
self.scaffold.save(doc)
|
||||
except (OSError, ValueError) as e:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": str(e)[:200]}],
|
||||
)
|
||||
closed = self.scaffold.contract(doc, contract_id)
|
||||
if closed is None:
|
||||
return _envelope(
|
||||
"close",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.STORE_WRITE_FAILED, "detail": "contract vanished after close"}],
|
||||
)
|
||||
q = closed["ext"]["contract_quality"]
|
||||
return _envelope(
|
||||
"close",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id},
|
||||
state={
|
||||
"phase": "closed",
|
||||
"contract_quality": {
|
||||
k: q[k]
|
||||
for k in ("correctly_predicted", "expected_new_observed", "genuinely_unexpected_new", "scope_check")
|
||||
if k in q
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def resume(self, contract_id: str) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"resume",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
ext = c.get("ext", {})
|
||||
t = ext.get("decision_trace", {})
|
||||
return _envelope(
|
||||
"resume",
|
||||
success=True,
|
||||
ids={"contract_id": contract_id, "need_id": t.get("need", {}).get("need_id", "")},
|
||||
state={
|
||||
"mode": self.mode,
|
||||
"need": t.get("need", {}),
|
||||
"decision": {
|
||||
"resolution": t.get("resolution"),
|
||||
"completeness": t.get("completeness", {}).get("verdict"),
|
||||
"toolchain": t.get("toolchain", {}).get("selected_language", ""),
|
||||
},
|
||||
"phase": c.get("phase"),
|
||||
"status": c.get("status"),
|
||||
"scope": c["identities"]["scope"],
|
||||
"obligations": _obligations_projection(c),
|
||||
"patterns": c.get("ext", {}).get("pattern_refs", []),
|
||||
"evidence": [cp.get("evidence", "")[:80] for cp in c.get("checkpoints", [])],
|
||||
"unresolved": ext.get("unresolved_surfaces", []),
|
||||
"approval": t.get("approval", {}),
|
||||
"next_action": c["identities"].get("next_action", ""),
|
||||
"last_builder_observations": [
|
||||
o
|
||||
for o in doc.get("observations", [])
|
||||
if o.get("kind") == "observed_consequence" and o.get("task") == contract_id
|
||||
][-5:],
|
||||
"builder": self.builder,
|
||||
},
|
||||
)
|
||||
|
||||
def amend_scope(self, contract_id: str, add_paths: list[str], *, reason: str, authority: str = "user") -> dict:
|
||||
if authority != "user":
|
||||
return _envelope(
|
||||
"amend_scope",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[
|
||||
{
|
||||
"error": Errors.APPROVAL_REQUIRED,
|
||||
"detail": "scope expansion requires user authority — builders cannot broaden their own scope",
|
||||
}
|
||||
],
|
||||
)
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
if c is None:
|
||||
return _envelope(
|
||||
"amend_scope",
|
||||
success=False,
|
||||
ids={"contract_id": contract_id},
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT, "detail": f"no contract {contract_id}"}],
|
||||
)
|
||||
c["identities"]["scope"] = sorted(set(c["identities"]["scope"]) | set(add_paths))
|
||||
c.setdefault("ext", {}).setdefault("scope_amendments", []).append(
|
||||
{"added": sorted(add_paths), "reason": reason[:200], "authorized_by": "user"}
|
||||
)
|
||||
self._record_session(doc, "amend_scope", f"{contract_id} +{len(add_paths)} paths (user)")
|
||||
self.scaffold.save(doc)
|
||||
return _envelope(
|
||||
"amend_scope", success=True, ids={"contract_id": contract_id}, state={"scope": c["identities"]["scope"]}
|
||||
)
|
||||
|
||||
# explicit method binding (readable, debuggable — no locals() tricks)
|
||||
|
||||
|
||||
def t_layout(ext: dict) -> list[dict]:
|
||||
"""Layout obligations from a contract's decision trace (helper)."""
|
||||
return ext.get("decision_trace", {}).get("layout_obligations", [])
|
||||
|
||||
|
||||
# ── §8 get_obligations / §9 report_observation / §10 verification ───────────
|
||||
|
||||
|
||||
def _obligations_projection(c: dict) -> list[dict]:
|
||||
t = c.get("ext", {}).get("decision_trace", {})
|
||||
obl = t.get("obligations", {})
|
||||
out = []
|
||||
for kind in ("regression", "verification", "composition", "acquisition"):
|
||||
for item in obl.get(kind, []):
|
||||
out.append(
|
||||
{
|
||||
"id": f"{kind}:{abs(hash(str(item))) % 10**8}",
|
||||
"description": str(item)[:200],
|
||||
"provenance": "STRUCTURAL",
|
||||
"kind": kind,
|
||||
"status": "REQUIRED",
|
||||
}
|
||||
)
|
||||
for p in obl.get("pattern", []):
|
||||
out.append(
|
||||
{
|
||||
"id": p["id"],
|
||||
"description": p["obligation"],
|
||||
"provenance": "PATTERN",
|
||||
"pattern": f"{p['pattern']}@r{p['revision']}",
|
||||
"pattern_applicability": p.get("pattern_applicability", ""),
|
||||
"evidence": p.get("reason", ""),
|
||||
"status": p.get("verdict", "REQUIRED"),
|
||||
}
|
||||
)
|
||||
for lo in t.get("layout_obligations", []):
|
||||
out.append(
|
||||
{
|
||||
"id": f"layout:{lo['component']}",
|
||||
"description": lo["obligation"],
|
||||
"provenance": "LAYOUT_CONTRACT",
|
||||
"status": "REQUIRED",
|
||||
"verification": lo["verification"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _obligation_scope_files(obligations: list[dict]) -> list[str]:
|
||||
"""Obligation ids referenced by closure scope checks."""
|
||||
return []
|
||||
|
||||
|
||||
# ── CLI transport (§3/§12/§27) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
"""JSON CLI — same convention as rune-spine/rune-scaffold mains. Every
|
||||
operation prints one JSON envelope; exit 0 = success, 1 = operation
|
||||
failure (the envelope carries the explicit error state)."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
cmd = argv[0]
|
||||
args = argv[1:]
|
||||
builder = "cli"
|
||||
project_store = None
|
||||
project_id = None
|
||||
# --builder / --project-store / --project-id flags
|
||||
for flag, key in (("--builder", "b"), ("--project-store", "s"), ("--project-id", "p")):
|
||||
while flag in args:
|
||||
i = args.index(flag)
|
||||
val = args[i + 1]
|
||||
del args[i : i + 2]
|
||||
if flag == "--builder":
|
||||
builder = val
|
||||
elif flag == "--project-store":
|
||||
project_store = val
|
||||
else:
|
||||
project_id = val
|
||||
|
||||
iface = ConstructionInterface(builder=builder, project_store=project_store, project_id=project_id)
|
||||
|
||||
def _out(env: dict) -> int:
|
||||
print(json.dumps(env, indent=1, default=str))
|
||||
return 0 if env["success"] else 1
|
||||
|
||||
if cmd == "capabilities":
|
||||
return _out(iface.capabilities())
|
||||
if cmd == "classify":
|
||||
payload = json.loads(args[0]) if args else {}
|
||||
return _out(iface.classify(
|
||||
payload.get("objective", ""),
|
||||
target_project=payload.get("target_project"),
|
||||
project_exists=payload.get("project_exists"),
|
||||
explicit_override=payload.get("explicit_override"),
|
||||
override_authority=payload.get("override_authority")))
|
||||
if cmd == "context":
|
||||
return _out(iface.construction_context(args[0] if args else None))
|
||||
if cmd == "submit-need":
|
||||
payload = json.loads(args[0]) if args else {}
|
||||
return _out(
|
||||
iface.submit_need(
|
||||
payload.get("objective", ""),
|
||||
origin=payload.get("origin", "builder"),
|
||||
constraints=payload.get("constraints"),
|
||||
affected_surface=payload.get("affected_surface"),
|
||||
evidence_refs=payload.get("evidence_refs"),
|
||||
approval_context=payload.get("approval_context", ""),
|
||||
generate=payload.get("generate", True),
|
||||
)
|
||||
)
|
||||
if cmd == "decision":
|
||||
return _out(iface.inspect_decision(args[0]))
|
||||
if cmd == "scope":
|
||||
return _out(iface.get_scope(args[0]))
|
||||
if cmd == "obligations":
|
||||
return _out(iface.get_obligations(args[0]))
|
||||
if cmd == "observe":
|
||||
return _out(iface.report_observation(args[0], json.loads(args[1])))
|
||||
if cmd == "verify":
|
||||
return _out(iface.verify(args[0], changed_files=(json.loads(args[1]) if len(args) > 1 else None)))
|
||||
if cmd == "transition":
|
||||
return _out(iface.transition(args[0], args[1], evidence_ref=args[2] if len(args) > 2 else ""))
|
||||
if cmd == "close":
|
||||
return _out(
|
||||
iface.close(
|
||||
args[0],
|
||||
observed_evidence=json.loads(args[1]),
|
||||
scope_files=(json.loads(args[2]) if len(args) > 2 else None),
|
||||
)
|
||||
)
|
||||
if cmd == "resume":
|
||||
return _out(iface.resume(args[0]))
|
||||
return _out(
|
||||
_envelope(cmd, success=False, errors=[{"error": "UNKNOWN_OPERATION", "detail": f"unknown command: {cmd}"}])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,309 @@
|
||||
"""construction_mode.py — contextual construction classification.
|
||||
|
||||
SCAFFOLD-TRANCHE-0010. ONE canonical home for the construction-mode
|
||||
decision (§2/§24: no competing router). Consumers — Rune's native backend
|
||||
path, the Construction Interface (MiMo/other builders), manual operator
|
||||
override — all call THIS.
|
||||
|
||||
The distinction (§3-4):
|
||||
|
||||
NOT_CONSTRUCTION not a build request at all
|
||||
LOCAL_CHANGE tiny fix / bounded patch — governance without
|
||||
ceremony (lightest path)
|
||||
EXISTING_PROJECT_CHANGE feature/repair/refactor/migration INSIDE an
|
||||
existing project — attaches to the existing
|
||||
Project Scaffold / Rune Spine
|
||||
PROJECT_BUILD genuinely NEW app/tool/service/project —
|
||||
fuller lifecycle (identity, repo decision,
|
||||
PRD state, bootstrap)
|
||||
UNKNOWN insufficient evidence — ask/probe, NEVER
|
||||
silently choose
|
||||
|
||||
KEY SEMANTIC DISTINCTION (§3): capability resolution BUILD_NEW does NOT
|
||||
imply PROJECT_BUILD. Building a new parser module inside Rune is
|
||||
EXISTING_PROJECT_CHANGE. Conversely "build me a standalone scheduler app"
|
||||
is PROJECT_BUILD before any capability decision exists. Tested explicitly.
|
||||
|
||||
NOT keyword-authoritative (§4): "build confidence" / "make a list" do not
|
||||
become PROJECT_BUILD. Deterministic evidence comes from:
|
||||
- explicit target project identity (constraint or recorded binding)
|
||||
- whether a Project Scaffold exists for the target
|
||||
- standalone/own-lifecycle language ("standalone", "new app",
|
||||
"new service", "own repository")
|
||||
- scoping words ("this", "existing", "the module") pointing at a
|
||||
known target
|
||||
Deterministic evidence is insufficient → UNKNOWN with probe questions.
|
||||
Model interpretation may be PROPOSED (recorded, PENDING — never authority,
|
||||
§5/§12 of the broader architecture).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["ConstructionMode", "classify_mode", "MODES"]
|
||||
|
||||
MODES = ("NOT_CONSTRUCTION", "LOCAL_CHANGE", "EXISTING_PROJECT_CHANGE", "PROJECT_BUILD", "UNKNOWN")
|
||||
|
||||
# deterministic lexical evidence — presence contributes, absence never
|
||||
# decides alone; every rule requires corroboration (§4)
|
||||
_STANDALONE_WORDS = {
|
||||
"standalone",
|
||||
"new app",
|
||||
"new application",
|
||||
"new service",
|
||||
"new tool",
|
||||
"new project",
|
||||
"new repository",
|
||||
"own repo",
|
||||
"from scratch",
|
||||
"separate app",
|
||||
"separate service",
|
||||
"greenfield",
|
||||
"scaffold a new",
|
||||
"new web",
|
||||
}
|
||||
_LOCAL_WORDS = {
|
||||
"typo",
|
||||
"small fix",
|
||||
"tiny fix",
|
||||
"quick fix",
|
||||
"patch",
|
||||
"minor",
|
||||
"one-liner",
|
||||
"cosmetic",
|
||||
"comment fix",
|
||||
"small correction",
|
||||
}
|
||||
# "this" alone is deixis without a referent — only NAMED-scope phrases
|
||||
# ("the module", "this project", ...) or an explicit target_project bind
|
||||
# scope. Bare "this" contributes nothing (Tranche 10 §4).
|
||||
_SCOPING_WORDS = {
|
||||
"existing",
|
||||
"the module",
|
||||
"the project",
|
||||
"the app",
|
||||
"the service",
|
||||
"current",
|
||||
"that module",
|
||||
"the codebase",
|
||||
"this module",
|
||||
"this project",
|
||||
"this app",
|
||||
}
|
||||
_CONSTRUCTION_WORDS = {
|
||||
"build",
|
||||
"create",
|
||||
"make",
|
||||
"implement",
|
||||
"add",
|
||||
"write",
|
||||
"develop",
|
||||
"construct",
|
||||
"fix",
|
||||
"repair",
|
||||
"refactor",
|
||||
"migrate",
|
||||
"change",
|
||||
"extend",
|
||||
"replace",
|
||||
"feature",
|
||||
"export",
|
||||
"module",
|
||||
"service",
|
||||
"app",
|
||||
"tool",
|
||||
"component",
|
||||
"schema",
|
||||
"api",
|
||||
}
|
||||
_NOT_CONSTRUCTION_HINTS = {
|
||||
"explain",
|
||||
"what is",
|
||||
"how does",
|
||||
"why does",
|
||||
"list",
|
||||
"confidence",
|
||||
"improvisation",
|
||||
"summarize",
|
||||
"describe",
|
||||
"teach me",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConstructionMode:
|
||||
"""Typed classification decision with full evidence provenance."""
|
||||
|
||||
mode: str
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
target_project: str | None = None
|
||||
project_authority: str | None = None # e.g. "rune_internal" | scaffold id
|
||||
confidence: float = 0.0
|
||||
clarification_required: bool = False
|
||||
probe_questions: list[str] = field(default_factory=list)
|
||||
bootstrap_required: bool = False
|
||||
override: dict | None = None # provenance if manually forced
|
||||
proposed_by_model: dict | None = None # recorded, never authority
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"evidence": self.evidence,
|
||||
"target_project": self.target_project,
|
||||
"project_authority": self.project_authority,
|
||||
"confidence": self.confidence,
|
||||
"clarification_required": self.clarification_required,
|
||||
"probe_questions": self.probe_questions,
|
||||
"bootstrap_required": self.bootstrap_required,
|
||||
"override": self.override,
|
||||
"proposed_by_model": self.proposed_by_model,
|
||||
}
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
return set(re.split(r"[^a-z0-9]+", text.lower()))
|
||||
|
||||
|
||||
def _contains_any(text: str, phrases) -> list[str]:
|
||||
t = text.lower()
|
||||
return [p for p in phrases if p in t]
|
||||
|
||||
|
||||
def classify_mode(
|
||||
objective: str,
|
||||
*,
|
||||
target_project: str | None = None,
|
||||
project_exists: bool | None = None,
|
||||
affected_surface: list[str] | None = None,
|
||||
explicit_override: str | None = None,
|
||||
override_authority: str | None = None,
|
||||
model_proposal: dict | None = None,
|
||||
) -> ConstructionMode:
|
||||
"""Classify the construction mode for one intent (§4-6).
|
||||
|
||||
`project_exists` — structural context: whether a Project Scaffold /
|
||||
project binding already exists for the target. Project existence
|
||||
MATERIALLY affects classification (§5/§20): the same request resolves
|
||||
differently with and without an existing project.
|
||||
|
||||
`explicit_override` — operator/builder authority feed (§11/§23):
|
||||
validated against known modes; provenance recorded. Override feeds the
|
||||
SAME machinery — it never bypasses the evidence record.
|
||||
"""
|
||||
ev: list[str] = []
|
||||
obj = objective or ""
|
||||
mode = None
|
||||
conf = 0.0
|
||||
bootstrap = False
|
||||
clarification = False
|
||||
probes: list[str] = []
|
||||
|
||||
# 1. explicit override (§23): authority-validated, provenance-carrying
|
||||
if explicit_override is not None:
|
||||
if explicit_override not in MODES:
|
||||
raise ValueError(f"override must be one of {MODES}, got {explicit_override!r}")
|
||||
ev.append(f"explicit override by {override_authority or 'operator'}")
|
||||
m = ConstructionMode(
|
||||
mode=explicit_override,
|
||||
evidence=ev,
|
||||
target_project=target_project,
|
||||
confidence=1.0,
|
||||
bootstrap_required=(explicit_override == "PROJECT_BUILD"),
|
||||
override={"authority": override_authority or "operator", "from_mode": None},
|
||||
)
|
||||
if explicit_override == "UNKNOWN":
|
||||
m.clarification_required = True
|
||||
return m
|
||||
|
||||
# 2. NOT_CONSTRUCTION: question/list/learning intents with no build verbs
|
||||
notc = _contains_any(obj.lower(), _NOT_CONSTRUCTION_HINTS)
|
||||
has_verb = bool(_tokens(obj) & _CONSTRUCTION_WORDS)
|
||||
if notc:
|
||||
# verb-object check: a construction verb whose object is a
|
||||
# non-constructible noun (list/confidence/explanation) is not a
|
||||
# construction request (§4: "build confidence", "make a list")
|
||||
non_constructible = {"list", "confidence", "explanation", "summary", "chord", "note"}
|
||||
obj_tokens = _tokens(obj)
|
||||
if not has_verb or obj_tokens & non_constructible:
|
||||
ev.append(f"non-construction intent markers: {notc[:3]}")
|
||||
ev.append(
|
||||
"construction-verb object is not a buildable artifact" if has_verb else "no construction verb present"
|
||||
)
|
||||
return ConstructionMode(mode="NOT_CONSTRUCTION", evidence=ev, confidence=0.85)
|
||||
|
||||
if not obj.strip():
|
||||
ev.append("empty objective")
|
||||
return ConstructionMode(
|
||||
mode="UNKNOWN",
|
||||
evidence=ev,
|
||||
confidence=0.0,
|
||||
clarification_required=True,
|
||||
probe_questions=["what should be built?"],
|
||||
)
|
||||
|
||||
standalone = _contains_any(obj.lower(), _STANDALONE_WORDS)
|
||||
local = _contains_any(obj.lower(), _LOCAL_WORDS)
|
||||
scoped = _contains_any(obj.lower(), _SCOPING_WORDS)
|
||||
|
||||
# 3. PROJECT_BUILD: standalone/new-lifecycle language AND no existing
|
||||
# project for the target (project existence materially matters, §5)
|
||||
if standalone:
|
||||
ev.append(f"standalone/new-lifecycle language: {standalone[:3]}")
|
||||
if project_exists is True:
|
||||
# §5: existing project preferred unless evidence says otherwise —
|
||||
# contradictory evidence → UNKNOWN, never silent choice
|
||||
ev.append("BUT target project already exists (structural evidence contradicts standalone language)")
|
||||
mode, conf, clarification = "UNKNOWN", 0.3, True
|
||||
probes.append("should this extend the existing project or start a genuinely separate one?")
|
||||
elif project_exists is None:
|
||||
ev.append("no project-existence evidence for the target")
|
||||
mode, conf, clarification = "UNKNOWN", 0.4, True
|
||||
probes.append("does a project for this already exist?")
|
||||
else:
|
||||
mode, conf = "PROJECT_BUILD", 0.9
|
||||
bootstrap = True
|
||||
# 4. LOCAL_CHANGE: explicit smallness + scoped target + no standalone
|
||||
elif local and (scoped or target_project or project_exists):
|
||||
ev.append(f"smallness markers: {local[:3]}")
|
||||
if scoped:
|
||||
ev.append(f"scoping words bind to known target: {scoped[:3]}")
|
||||
mode, conf = "LOCAL_CHANGE", 0.8
|
||||
# 5. EXISTING_PROJECT_CHANGE: scoping to a known/existing target
|
||||
elif (scoped or target_project or project_exists is True) and has_verb:
|
||||
ev.append("change scoped to an existing project/target")
|
||||
if target_project:
|
||||
ev.append(f"target project: {target_project}")
|
||||
mode, conf = "EXISTING_PROJECT_CHANGE", 0.8
|
||||
# 6. construction verb but no scope and no standalone language → UNKNOWN
|
||||
elif has_verb:
|
||||
ev.append("construction verb present but no target-scope evidence and no standalone/new-lifecycle language")
|
||||
mode, conf, clarification = "UNKNOWN", 0.35, True
|
||||
probes.append("is this a change to an existing project or a new standalone one?")
|
||||
probes.append("which project/module is the target?")
|
||||
else:
|
||||
ev.append("insufficient lexical evidence either way")
|
||||
mode, conf, clarification = "UNKNOWN", 0.2, True
|
||||
probes.append("is this a construction request?")
|
||||
|
||||
if affected_surface and mode in ("LOCAL_CHANGE", "EXISTING_PROJECT_CHANGE", "UNKNOWN"):
|
||||
ev.append(f"affected surface known: {affected_surface[:2]}")
|
||||
if mode == "UNKNOWN":
|
||||
mode, conf = "EXISTING_PROJECT_CHANGE", 0.6
|
||||
clarification = False
|
||||
|
||||
# §5 model proposal: recorded, never authority
|
||||
proposed = model_proposal if model_proposal else None
|
||||
|
||||
return ConstructionMode(
|
||||
mode=mode,
|
||||
evidence=ev,
|
||||
target_project=target_project,
|
||||
project_authority=("rune_internal" if target_project in (None, "rune", "issue161-harness") else target_project),
|
||||
confidence=conf,
|
||||
clarification_required=clarification,
|
||||
probe_questions=probes,
|
||||
bootstrap_required=(mode == "PROJECT_BUILD"),
|
||||
proposed_by_model=proposed,
|
||||
)
|
||||
@@ -0,0 +1,496 @@
|
||||
"""construction_pattern.py — reusable evidence about HOW a class of thing
|
||||
is normally built.
|
||||
|
||||
SCAFFOLD-TRANCHE-006. Canonical distinction (tranche brief):
|
||||
|
||||
Construction Pattern = reusable knowledge for a CLASS of construction
|
||||
Project Scaffold = structural truth for THIS project
|
||||
Construction Contract = obligations for THIS change
|
||||
|
||||
A pattern is GUIDANCE, not authority (§3): project evidence always wins.
|
||||
A pattern must never overwrite contradictory project evidence — if the
|
||||
pattern assumes HTTP services and the project evidence says message-queue-
|
||||
only, the pattern adapts or becomes non-applicable.
|
||||
|
||||
Applicability (§4-5) is EVIDENCE-BACKED and deterministic: signals are
|
||||
checked against the affected surface + structural authorities. Verdicts:
|
||||
APPLIES / PARTIAL / DOES_NOT_APPLY / UNKNOWN. UNKNOWN never becomes APPLIES
|
||||
because a name sounds similar; name similarity establishes nothing. Model
|
||||
reasoning may PROPOSE a pattern (recorded, PENDING) — it may not certify
|
||||
applicability.
|
||||
|
||||
Obligation derivation (§6-7) is FILTERED by this project: every pattern
|
||||
obligation carries a `when` condition evaluated against project evidence;
|
||||
verdicts REQUIRED / APPLICABLE / NOT_APPLICABLE / UNKNOWN with the reason.
|
||||
Patterns REDUCE false-positive obligation expansion, never amplify it.
|
||||
|
||||
Provenance (§16): every rule answers "why is this here" with real Rune
|
||||
evidence — incidents, contracts, tests, user requirements. Patterns
|
||||
accumulate verified engineering knowledge, not model folklore.
|
||||
|
||||
Revision (§17): lightweight revision identity; contracts record the
|
||||
revision consumed so "which builds used the old rule" stays answerable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune import structural_store as ss
|
||||
|
||||
__all__ = [
|
||||
"ConstructionPattern",
|
||||
"evaluate_applicability",
|
||||
"derive_pattern_obligations",
|
||||
"record_pattern_proposal",
|
||||
"APPLIES",
|
||||
"PARTIAL",
|
||||
"DOES_NOT_APPLY",
|
||||
"UNKNOWN",
|
||||
"OBL_REQUIRED",
|
||||
"OBL_APPLICABLE",
|
||||
"OBL_NOT_APPLICABLE",
|
||||
"OBL_UNKNOWN",
|
||||
]
|
||||
|
||||
APPLIES = "APPLIES"
|
||||
PARTIAL = "PARTIAL"
|
||||
DOES_NOT_APPLY = "DOES_NOT_APPLY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
OBL_REQUIRED = "REQUIRED"
|
||||
OBL_APPLICABLE = "APPLICABLE"
|
||||
OBL_NOT_APPLICABLE = "NOT_APPLICABLE"
|
||||
OBL_UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
# ── pattern object (open-world) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConstructionPattern:
|
||||
"""One reusable construction pattern. Open-world: `ext` carries anything
|
||||
the schema above does not name. NOT structural truth — guidance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pattern_id: str,
|
||||
purpose: str,
|
||||
*,
|
||||
revision: int = 1,
|
||||
maturity: str = "CANDIDATE",
|
||||
signals: dict | None = None,
|
||||
obligations: list[dict] | None = None,
|
||||
provenance: list[str] | None = None,
|
||||
exclusions: list[str] | None = None,
|
||||
toolchain_notes: list[str] | None = None,
|
||||
runtime_effects: list[str] | None = None,
|
||||
**ext,
|
||||
):
|
||||
if not pattern_id:
|
||||
raise ValueError("pattern_id required")
|
||||
if not purpose:
|
||||
raise ValueError("purpose required")
|
||||
for o in obligations or []:
|
||||
for key in ("id", "obligation", "when"):
|
||||
if key not in o:
|
||||
raise ValueError(f"obligation missing {key!r}: {o}")
|
||||
self.pattern_id = pattern_id
|
||||
self.purpose = purpose
|
||||
self.revision = revision
|
||||
self.maturity = maturity
|
||||
self.signals = dict(signals or {})
|
||||
self.obligations = list(obligations or [])
|
||||
self.provenance = sorted(provenance or [])
|
||||
self.exclusions = sorted(exclusions or [])
|
||||
self.toolchain_notes = sorted(toolchain_notes or [])
|
||||
self.runtime_effects = sorted(runtime_effects or [])
|
||||
self.ext = dict(ext)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pattern_id": self.pattern_id,
|
||||
"purpose": self.purpose,
|
||||
"revision": self.revision,
|
||||
"maturity": self.maturity,
|
||||
"signals": dict(self.signals),
|
||||
"obligations": [dict(o) for o in self.obligations],
|
||||
"provenance": list(self.provenance),
|
||||
"exclusions": list(self.exclusions),
|
||||
"toolchain_notes": list(self.toolchain_notes),
|
||||
"runtime_effects": list(self.runtime_effects),
|
||||
**self.ext,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ConstructionPattern":
|
||||
src = dict(d) # never mutate the caller's dict
|
||||
known = {
|
||||
k: src.pop(k)
|
||||
for k in (
|
||||
"pattern_id",
|
||||
"purpose",
|
||||
"revision",
|
||||
"maturity",
|
||||
"signals",
|
||||
"obligations",
|
||||
"provenance",
|
||||
"exclusions",
|
||||
"toolchain_notes",
|
||||
"runtime_effects",
|
||||
)
|
||||
if k in src
|
||||
}
|
||||
return cls(**known, **src)
|
||||
|
||||
def revise(self, *, reason: str, evidence: list[str], changes: dict | None = None) -> "ConstructionPattern":
|
||||
"""Revision with provenance (§17): prior meaning not invisibly
|
||||
mutated — returns a NEW pattern at revision+1 recording the change."""
|
||||
d = self.to_dict()
|
||||
d.pop("pattern_id")
|
||||
rev = {**d, **(changes or {})}
|
||||
rev["revision"] = self.revision + 1
|
||||
rev.setdefault("revision_history", []).append(
|
||||
{
|
||||
"prior_revision": self.revision,
|
||||
"reason": reason[:200],
|
||||
"evidence": sorted(evidence),
|
||||
"changed": sorted((changes or {}).keys()),
|
||||
}
|
||||
)
|
||||
return ConstructionPattern(self.pattern_id, **rev)
|
||||
|
||||
|
||||
# ── model proposal seam (§5 — recorded, never authoritative) ────────────────
|
||||
|
||||
|
||||
def record_pattern_proposal(
|
||||
pattern_id: str, *, provider: str, model: str, evidence: list[str], confidence: float
|
||||
) -> dict:
|
||||
"""A model may PROPOSE a pattern; it may not certify applicability.
|
||||
Recorded with verification state PENDING until deterministic project
|
||||
evidence, research evidence, proven construction evidence, or an
|
||||
explicit user decision accepts it."""
|
||||
if not provider or not model:
|
||||
raise ValueError("pattern proposal requires provider+model")
|
||||
if not 0.0 <= confidence <= 1.0:
|
||||
raise ValueError("confidence must be in [0,1]")
|
||||
return {
|
||||
"kind": "pattern_proposal",
|
||||
"pattern_id": pattern_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"evidence": sorted(evidence),
|
||||
"confidence": confidence,
|
||||
"verification_state": "PENDING",
|
||||
}
|
||||
|
||||
|
||||
# ── applicability (§4 — deterministic signals only) ─────────────────────────
|
||||
|
||||
|
||||
def _surface_text(need, surface_result: dict) -> set[str]:
|
||||
"""Everything the affected surface offers as evidence: paths, entities,
|
||||
ui surfaces, regions, workflow configs, the objective text tokens."""
|
||||
tokens = set()
|
||||
for k in (
|
||||
"affected_paths",
|
||||
"affected_existing_entities",
|
||||
"affected_ui_surfaces",
|
||||
"affected_regions",
|
||||
"workflow_config_surfaces",
|
||||
):
|
||||
tokens.update(surface_result.get(k, []))
|
||||
tokens.update(need.affected_surface)
|
||||
# objective word tokens (deterministic split — not semantics)
|
||||
tokens.update(w.lower() for w in need.objective.replace("/", " ").replace("-", " ").split())
|
||||
return tokens
|
||||
|
||||
|
||||
# ── shared boundary-aware matchers (Tranche 7 §0) ──────────────────────────
|
||||
|
||||
def _path_matches(signal: str, path: str) -> bool:
|
||||
"""Boundary-aware path-signal matching: short (<4 char) signals match
|
||||
path TOKENS only (the 'ui'-in-'builtin' false-positive class); longer
|
||||
signals may use containment."""
|
||||
import re as _re
|
||||
|
||||
s = signal.lower()
|
||||
if len(s) >= 4:
|
||||
return s in path.lower()
|
||||
return s in set(_re.split(r"[^a-z0-9]+", path.lower()))
|
||||
|
||||
|
||||
def _entity_matches(signal: str, entity_id: str) -> bool:
|
||||
"""Same discipline for entity ids. Prefix signals ending in ':' (e.g.
|
||||
'ui:', 'region:') match the id's namespace prefix — structural kinds
|
||||
carry meaning in their prefix, and prefix equality is exact, not a
|
||||
substring accident."""
|
||||
import re as _re
|
||||
|
||||
s = signal.lower()
|
||||
if s.endswith(":"):
|
||||
return entity_id.lower().startswith(s)
|
||||
if len(s) >= 4:
|
||||
return s in entity_id.lower()
|
||||
return s in set(_re.split(r"[^a-z0-9:]+", entity_id.lower()))
|
||||
|
||||
|
||||
def evaluate_applicability(
|
||||
pattern: ConstructionPattern, need, surface_result: dict, authorities: dict[str, dict]
|
||||
) -> dict:
|
||||
"""Deterministic applicability verdict from the pattern's declared
|
||||
signals (§4). Signals (all optional; evidence-checked against the
|
||||
affected surface and structural authorities):
|
||||
surface_kinds subset of {source_path, workflow_config,
|
||||
ui_component, layout_region,
|
||||
structural_entity}
|
||||
path_contains any-of substrings expected in affected paths
|
||||
entities_match any-of entity-id prefixes/substrings
|
||||
objective_any any-of lowercase words expected in the objective
|
||||
contracts_match any-of contract ids implemented by affected entities
|
||||
|
||||
Verdict: count how many DECLARED signal groups produce evidence.
|
||||
all declared groups hit -> APPLIES
|
||||
some hit -> PARTIAL (or DOES_NOT_APPLY when the
|
||||
pattern's `strong` groups all miss)
|
||||
none / no signals -> UNKNOWN (never APPLIES by name)
|
||||
Project evidence overrides (§3): exclusions evaluated first — if the
|
||||
need explicitly declares the pattern non-applicable, DOES_NOT_APPLY
|
||||
with the exclusion recorded.
|
||||
"""
|
||||
# §3/§18: PROJECT evidence overrides the pattern. Exclusions may be
|
||||
# declared by the project/need (pattern_exclusions constraint) or by the
|
||||
# pattern itself (non-applicability domains).
|
||||
declared_exclusions = list(pattern.exclusions) + list(need.constraints.get("pattern_exclusions", []))
|
||||
for ex in declared_exclusions:
|
||||
if ex.lower() in need.objective.lower() or ex in need.constraints.get("pattern_exclusions", []):
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": DOES_NOT_APPLY,
|
||||
"reason": f"declared exclusion matched: {ex}",
|
||||
"evidence": {"exclusion": ex, "source": "project/need declaration"},
|
||||
}
|
||||
|
||||
signals = pattern.signals
|
||||
if not signals:
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": UNKNOWN,
|
||||
"reason": "pattern declares no applicability signals — nothing deterministic to check",
|
||||
"evidence": {},
|
||||
}
|
||||
tokens = _surface_text(need, surface_result)
|
||||
|
||||
groups_hit, groups_declared = [], 0
|
||||
evidence: dict = {}
|
||||
|
||||
def _check(group: str, candidates: list[str], present) -> None:
|
||||
nonlocal groups_declared
|
||||
if not candidates:
|
||||
return
|
||||
groups_declared += 1
|
||||
hits = [c for c in candidates if present(c)]
|
||||
if hits:
|
||||
groups_hit.append(group)
|
||||
evidence[group] = hits[:5]
|
||||
|
||||
_check(
|
||||
"surface_kinds",
|
||||
signals.get("surface_kinds", []),
|
||||
lambda c: (
|
||||
bool(
|
||||
surface_result.get(
|
||||
{
|
||||
"source_path": "affected_paths",
|
||||
"workflow_config": "workflow_config_surfaces",
|
||||
"ui_component": "affected_ui_surfaces",
|
||||
"layout_region": "affected_regions",
|
||||
}.get(c, ""),
|
||||
[],
|
||||
)
|
||||
)
|
||||
or any(r["kind"] == c for r in surface_result.get("resolutions", []))
|
||||
),
|
||||
)
|
||||
_check(
|
||||
"path_contains",
|
||||
[p for p in signals.get("path_contains", [])],
|
||||
lambda c: any(
|
||||
_path_matches(c, p)
|
||||
for p in surface_result.get("affected_paths", []) + surface_result.get("workflow_config_surfaces", [])
|
||||
),
|
||||
)
|
||||
_check(
|
||||
"entities_match",
|
||||
[e for e in signals.get("entities_match", [])],
|
||||
lambda c: any(
|
||||
_entity_matches(c, e) for e in surface_result.get("affected_existing_entities", [])
|
||||
),
|
||||
)
|
||||
_check(
|
||||
"objective_any",
|
||||
[w.lower() for w in signals.get("objective_any", [])],
|
||||
lambda c: c in {w.lower() for w in need.objective.replace("/", " ").split()},
|
||||
)
|
||||
contracts_match = signals.get("contracts_match", [])
|
||||
if contracts_match:
|
||||
groups_declared += 1
|
||||
hits = []
|
||||
for name, doc in authorities.items():
|
||||
for e in surface_result.get("affected_existing_entities", []):
|
||||
for r in ss.relationships(doc, from_id=e):
|
||||
if r["type"] in {"implements", "exposes", "provides", "serves"} and r["to"] in contracts_match:
|
||||
hits.append(f"{e}->{r['to']}")
|
||||
if hits:
|
||||
groups_hit.append("contracts_match")
|
||||
evidence["contracts_match"] = hits[:5]
|
||||
|
||||
if groups_declared == 0:
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": UNKNOWN,
|
||||
"reason": "no applicable signal groups declared",
|
||||
"evidence": {},
|
||||
}
|
||||
if len(groups_hit) == groups_declared:
|
||||
verdict = APPLIES
|
||||
elif groups_hit:
|
||||
verdict = PARTIAL
|
||||
else:
|
||||
verdict = DOES_NOT_APPLY
|
||||
return {
|
||||
"pattern": pattern.pattern_id,
|
||||
"verdict": verdict,
|
||||
"reason": f"{len(groups_hit)}/{groups_declared} signal groups "
|
||||
f"produced evidence: {sorted(groups_hit) or 'none'}",
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
# ── obligation derivation (§6-7 — filtered by project evidence) ─────────────
|
||||
|
||||
|
||||
def _obligation_when_hit(obligation: dict, need, surface_result: dict, authorities: dict[str, dict]) -> tuple[str, str]:
|
||||
"""Evaluate one obligation's `when` condition against project evidence.
|
||||
Returns (verdict, reason). `when` shapes (all deterministic):
|
||||
path_contains: [substrings] — any affected/config path matches
|
||||
entities_match: [substrings] — any affected entity matches
|
||||
always: true — applies whenever the pattern applies
|
||||
contracts_touched: [ids] — contract in requirement or implemented
|
||||
by affected entities
|
||||
An obligation with no recognized `when` is UNKNOWN (never silently
|
||||
required)."""
|
||||
when = obligation.get("when")
|
||||
paths = [p for p in surface_result.get("affected_paths", [])] + [
|
||||
p for p in surface_result.get("workflow_config_surfaces", [])
|
||||
]
|
||||
entities = (
|
||||
[e for e in surface_result.get("affected_existing_entities", [])]
|
||||
+ [u for u in surface_result.get("affected_ui_surfaces", [])]
|
||||
+ [r for r in surface_result.get("affected_regions", [])]
|
||||
)
|
||||
if when is None:
|
||||
return OBL_UNKNOWN, "obligation declares no applicability condition"
|
||||
if when.get("always") is True:
|
||||
return OBL_REQUIRED, "pattern-level obligation (always applies when the pattern applies)"
|
||||
if "path_contains" in when:
|
||||
hits = [p for sub in when["path_contains"] for p in paths if _path_matches(sub, p)]
|
||||
if hits:
|
||||
return OBL_APPLICABLE, f"affected path(s): {hits[:3]}"
|
||||
return OBL_NOT_APPLICABLE, ("no affected path matches " + str(when["path_contains"]))
|
||||
if "entities_match" in when:
|
||||
hits = [e for sub in when["entities_match"] for e in entities if _entity_matches(sub, e)]
|
||||
if hits:
|
||||
return OBL_APPLICABLE, f"affected entity/entities: {hits[:3]}"
|
||||
return OBL_NOT_APPLICABLE, ("no affected entity matches " + str(when["entities_match"]))
|
||||
if "contracts_touched" in when:
|
||||
touched = set(need.constraints.get("contracts", []))
|
||||
for name, doc in authorities.items():
|
||||
for e in entities:
|
||||
for r in ss.relationships(doc, from_id=e):
|
||||
if r["type"] in {"implements", "exposes", "provides", "serves"}:
|
||||
touched.add(r["to"])
|
||||
hits = [c for c in when["contracts_touched"] if c in touched]
|
||||
if hits:
|
||||
return OBL_APPLICABLE, f"contract(s) touched: {hits}"
|
||||
return OBL_NOT_APPLICABLE, ("touched contracts do not include " + str(when["contracts_touched"]))
|
||||
return OBL_UNKNOWN, f"unrecognized when-condition shape: {sorted(when)}"
|
||||
|
||||
|
||||
def derive_pattern_obligations(
|
||||
pattern: ConstructionPattern,
|
||||
need,
|
||||
surface_result: dict,
|
||||
authorities: dict[str, dict],
|
||||
project_exceptions: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Derive this pattern's obligations FILTERED by project evidence (§7).
|
||||
Every returned obligation carries: pattern, revision, obligation text,
|
||||
verdict, reason/evidence, and provenance kind PATTERN (distinct from
|
||||
Tranche 5's STRUCTURAL derivation — §8 provenance separation).
|
||||
Project exceptions (§18) suppress with provenance retained."""
|
||||
project_exceptions = list(project_exceptions or [])
|
||||
out = {
|
||||
"pattern": pattern.pattern_id,
|
||||
"revision": pattern.revision,
|
||||
"accepted": [],
|
||||
"rejected": [],
|
||||
"unresolved": [],
|
||||
}
|
||||
for o in pattern.obligations:
|
||||
entry = {
|
||||
"id": o["id"],
|
||||
"obligation": o["obligation"],
|
||||
"pattern": pattern.pattern_id,
|
||||
"revision": pattern.revision,
|
||||
"provenance": "PATTERN",
|
||||
"pattern_provenance": list(pattern.provenance),
|
||||
}
|
||||
# project exception check first (§18) — suppress with provenance
|
||||
exc = next((x for x in project_exceptions if x.get("obligation") == o["id"]), None)
|
||||
if exc:
|
||||
entry.update(
|
||||
{
|
||||
"verdict": "EXCEPTED",
|
||||
"reason": exc.get("reason", ""),
|
||||
"exception_evidence": exc.get("evidence", ""),
|
||||
"exception_authority": exc.get("authority", "user"),
|
||||
}
|
||||
)
|
||||
out["rejected"].append(entry)
|
||||
continue
|
||||
verdict, reason = _obligation_when_hit(o, need, surface_result, authorities)
|
||||
entry["verdict"] = verdict
|
||||
entry["reason"] = reason
|
||||
if verdict in (OBL_REQUIRED, OBL_APPLICABLE):
|
||||
out["accepted"].append(entry)
|
||||
elif verdict == OBL_NOT_APPLICABLE:
|
||||
out["rejected"].append(entry)
|
||||
else:
|
||||
out["unresolved"].append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def combine_pattern_obligations(derivations: list[dict]) -> dict:
|
||||
"""Combine multiple patterns' derivations (§26-27): duplicates of the
|
||||
same obligation id collapse (provenance lists merge); conflicting
|
||||
obligations (same id, contradictory verdicts across patterns) surface
|
||||
as conflicts requiring evidence/user decision — never silently chosen."""
|
||||
merged: dict[str, dict] = {}
|
||||
conflicts: list[dict] = []
|
||||
for d in derivations:
|
||||
for entry in d["accepted"]:
|
||||
oid = entry["id"]
|
||||
if oid in merged:
|
||||
prev = merged[oid]
|
||||
if prev["obligation"] != entry["obligation"]:
|
||||
conflicts.append(
|
||||
{
|
||||
"obligation_id": oid,
|
||||
"conflict": "same id, different text",
|
||||
"sources": [prev["pattern"], entry["pattern"]],
|
||||
}
|
||||
)
|
||||
continue
|
||||
prev.setdefault("also_from", []).append(entry["pattern"])
|
||||
continue
|
||||
merged[oid] = dict(entry)
|
||||
return {"obligations": sorted(merged.values(), key=lambda x: x["id"]), "conflicts": conflicts}
|
||||
@@ -0,0 +1,249 @@
|
||||
"""construction_patterns_builtin.py — the initial evidence-grounded pattern
|
||||
set (SCAFFOLD-TRANCHE-006 §10-14: 4 patterns, each derived from REAL Rune
|
||||
evidence, each appropriately immature).
|
||||
|
||||
This module is DATA, not a framework: pattern provenance answers "why is
|
||||
this rule here" with real incidents/contracts/tests. Maturity stays
|
||||
CANDIDATE/UNIT-PROVEN — existence is not trust (§15).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune.construction_pattern import ConstructionPattern
|
||||
|
||||
__all__ = ["BUILTIN_PATTERNS", "get_pattern", "PATTERN_REGISTRY_REVISION"]
|
||||
|
||||
# Bumped when any builtin pattern's obligations change meaningfully.
|
||||
PATTERN_REGISTRY_REVISION = 1
|
||||
|
||||
CAPABILITY_ADAPTATION = ConstructionPattern(
|
||||
"capability-module-adaptation",
|
||||
"Adapting or extending an existing capability/module: prove the NEW "
|
||||
"behavior, preserve the OLD behavior (BUILD BUT DON'T BREAK).",
|
||||
maturity="UNIT-PROVEN",
|
||||
signals={
|
||||
"entities_match": ["module:", "capability:"],
|
||||
"path_contains": ["src/"],
|
||||
"objective_any": ["adapt", "extend", "modify", "preserve", "existing", "capability", "repair"],
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "adapt-new-behavior-proof",
|
||||
"obligation": "new behavior proven by tests before closure",
|
||||
"when": {"always": True},
|
||||
},
|
||||
{
|
||||
"id": "adapt-old-behavior-preserved",
|
||||
"obligation": "existing capability behavior preserved; original contracts remain valid",
|
||||
"when": {"always": True},
|
||||
},
|
||||
{
|
||||
"id": "adapt-consumers-verified",
|
||||
"obligation": "existing consumers of the adapted capability verified against their contracts",
|
||||
"when": {"entities_match": ["module:", "capability:"]},
|
||||
},
|
||||
{
|
||||
"id": "adapt-migration-detected",
|
||||
"obligation": "migration obligations detected and recorded if any consumer-visible interface changes",
|
||||
"when": {"path_contains": ["src/"]},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"SCAFFOLD-TRANCHE-001 (build-but-don't-break established)",
|
||||
"SCAFFOLD-TRANCHE-003 (adaptation two-proof rule)",
|
||||
"construction_decision.obligations_from_trace (auto-derived since T4)",
|
||||
"LIVE-CONTRACT-QUALITY-001 (adaptation resolved + closed live)",
|
||||
],
|
||||
)
|
||||
|
||||
RELEASE_DEPLOYMENT = ConstructionPattern(
|
||||
"release-deployment-change",
|
||||
"Changes to the release/deployment path: fail-closed qualification, "
|
||||
"exact candidate identity, staging != promotion, truthful promotion "
|
||||
"outcomes, deployed-identity verification, negative + forward proofs.",
|
||||
maturity="UNIT-PROVEN",
|
||||
signals={
|
||||
"path_contains": [
|
||||
"deploy",
|
||||
"promotion",
|
||||
"qualify",
|
||||
"release.yml",
|
||||
"ci.yml",
|
||||
"deploy_qualify",
|
||||
"deploy_stage",
|
||||
"deploy_promotion",
|
||||
],
|
||||
"objective_any": ["deploy", "release", "promot", "qualif", "candidate", "current", "monotonic"],
|
||||
"workflow_config_signals": True, # informational; surface kinds carry it
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "rel-fail-closed-qualification",
|
||||
"obligation": "CI qualification remains fail-closed (exact SHA, "
|
||||
"two-leg verdict); red candidates never deploy",
|
||||
"when": {"path_contains": ["deploy", "release", "qualify", "ci.yml"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-exact-candidate-identity",
|
||||
"obligation": "deployment addresses the EXACT pushed revision, never whatever HEAD is at deploy time",
|
||||
"when": {"path_contains": ["deploy", "release"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-staging-promotion-distinction",
|
||||
"obligation": "staged candidate != promoted candidate; promotion outcome must be independently verifiable",
|
||||
"when": {"path_contains": ["deploy", "promotion", "stage"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-promotion-truthfulness",
|
||||
"obligation": "promotion outcomes are truthful (PROMOTED / "
|
||||
"NOOP_ALREADY_CURRENT / STALE_SUPERSEDED / REFUSED_*); "
|
||||
"stale candidates never claim success",
|
||||
"when": {"path_contains": ["promotion", "deploy_stage", "deploy_promotion"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-monotonic-promotion",
|
||||
"obligation": "promotion ordering: an older candidate finishing "
|
||||
"later cannot regress current; ancestry check + "
|
||||
"fenced critical section",
|
||||
"when": {"path_contains": ["deploy_promotion", "deploy_stage", "promotion"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-negative-candidate-proof",
|
||||
"obligation": "negative proof: red/unqualified candidate is refused; stale candidate cannot promote",
|
||||
"when": {"path_contains": ["deploy", "promotion", "qualify"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-forward-candidate-proof",
|
||||
"obligation": "forward proof: a green newer candidate still promotes normally",
|
||||
"when": {"path_contains": ["deploy", "promotion"]},
|
||||
},
|
||||
{
|
||||
"id": "rel-deployed-identity-verification",
|
||||
"obligation": "deployed identity verified from the deployment "
|
||||
"authority (current symlink + build identity), not "
|
||||
"from job artifacts alone",
|
||||
"when": {"always": True},
|
||||
},
|
||||
{
|
||||
"id": "rel-release-regression-verification",
|
||||
"obligation": "existing release-safety guarantees re-proven (the "
|
||||
"guarantees that already exist must not silently "
|
||||
"weaken)",
|
||||
"when": {"always": True},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"maintenance:deploy-order-race (live incident: deploys 7355/7359)",
|
||||
"DEPLOY-MONOTONIC-001 (monotonic promotion + truthful outcomes, live-proven)",
|
||||
"release-safety repair 2026-09-13 (red-promotion incidents 0.66.28/29: required-checks aggregator lied)",
|
||||
"scripts/deploy_promotion.py (ancestry + fenced critical section)",
|
||||
"tests/test_deploy_stage_race.py (22-test matrix incl. two-process live race)",
|
||||
],
|
||||
)
|
||||
|
||||
UI_TUI_STRUCTURAL = ConstructionPattern(
|
||||
"ui-tui-structural-change",
|
||||
"UI/TUI structural changes: component identity, Layout Contract "
|
||||
"inheritance, USER-LOCKED preservation, deterministic verification.",
|
||||
maturity="UNIT-PROVEN",
|
||||
signals={
|
||||
"surface_kinds": ["ui_component", "layout_region"],
|
||||
"path_contains": ["tui", "ui", "widget"],
|
||||
"entities_match": ["ui:", "region:", "layout:"],
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "ui-layout-contract-inherited",
|
||||
"obligation": "affected UI components inherit their Layout Contract "
|
||||
"obligations; layout_contract_check must PASS",
|
||||
"when": {"entities_match": ["ui:", "region:", "layout:"]},
|
||||
},
|
||||
{
|
||||
"id": "ui-user-locked-preserved",
|
||||
"obligation": "USER-LOCKED placement is user authority: preserved "
|
||||
"exactly; changes require explicit user authorization",
|
||||
"when": {"entities_match": ["ui:", "region:"]},
|
||||
},
|
||||
{
|
||||
"id": "ui-unknown-never-pass",
|
||||
"obligation": "layout verification UNKNOWN (no adapter evidence) must never be reported as PASS",
|
||||
"when": {"entities_match": ["ui:", "region:", "layout:"]},
|
||||
},
|
||||
{
|
||||
"id": "ui-component-identity-stable",
|
||||
"obligation": "ui:<component> identity survives implementation-path "
|
||||
"changes; authority never keyed by filename alone",
|
||||
"when": {"entities_match": ["ui:", "region:", "layout:"]},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"issue #105 + plans/2026-09-03-tui-footer.md (version display)",
|
||||
"Tranche 3 real TUI proof (gate PASS -> violation FAIL -> restore)",
|
||||
"tests/test_layout_contract.py + tests/test_layout_contract_real_proof.py",
|
||||
"maintenance:version-display-user-lock (USER-LOCK upgrade pending "
|
||||
"user confirmation — provenance rule honored, not fabricated)",
|
||||
],
|
||||
)
|
||||
|
||||
STORE_SCHEMA_EVOLUTION = ConstructionPattern(
|
||||
"store-schema-evolution",
|
||||
"Durable store/schema changes: backward readability, authority "
|
||||
"preservation, deterministic roundtrip, migration only when necessary.",
|
||||
maturity="CANDIDATE",
|
||||
signals={
|
||||
"path_contains": ["store", "schema", "structural_store", "scaffold", "spine"],
|
||||
"entities_match": ["module:structural_store", "module:scaffold", "module:spine"],
|
||||
"objective_any": ["store", "schema", "record", "persist", "field", "migration"],
|
||||
},
|
||||
obligations=[
|
||||
{
|
||||
"id": "store-backward-readability",
|
||||
"obligation": "old records without new fields read as empty/unknown, never crash; legacy keys preserved",
|
||||
"when": {"path_contains": ["store", "structural_store", "scaffold", "spine", "schema"]},
|
||||
},
|
||||
{
|
||||
"id": "store-roundtrip",
|
||||
"obligation": "deterministic save/load roundtrip incl. unknown entity/relation kinds and open-world fields",
|
||||
"when": {"path_contains": ["store", "structural_store", "scaffold", "schema"]},
|
||||
},
|
||||
{
|
||||
"id": "store-authority-preservation",
|
||||
"obligation": "structural facts only change through their owning "
|
||||
"projector/authority; canonical rebuild after "
|
||||
"registration (no hand-edited structural facts)",
|
||||
"when": {"path_contains": ["spine", "scaffold", "store/"]},
|
||||
},
|
||||
{
|
||||
"id": "store-migration-only-if-needed",
|
||||
"obligation": "no mandatory migration unless semantics truly change; "
|
||||
"ext/open-world fields preferred over schema churn",
|
||||
"when": {"path_contains": ["schema", "store", "structural_store"]},
|
||||
},
|
||||
],
|
||||
provenance=[
|
||||
"Tranche 2 structural-store extraction (behavior-neutrality proof)",
|
||||
"Tranche 4 contract-quality repair (legacy keys preserved, old contracts readable — test_h)",
|
||||
"Tranche 5 expected-runtime-effects extension (old contracts close without new fields)",
|
||||
"Tranche 2 CI lesson: hand-edited spine store diverged from canonical rebuild (deploy-identity edges)",
|
||||
],
|
||||
)
|
||||
|
||||
BUILTIN_PATTERNS = [CAPABILITY_ADAPTATION, RELEASE_DEPLOYMENT, UI_TUI_STRUCTURAL, STORE_SCHEMA_EVOLUTION]
|
||||
|
||||
_REGISTRY: dict[str, ConstructionPattern] = {p.pattern_id: p for p in BUILTIN_PATTERNS}
|
||||
|
||||
|
||||
def get_pattern(pattern_id: str, revision: int | None = None) -> ConstructionPattern | None:
|
||||
"""Fetch a builtin pattern; a specific revision raises if the registry
|
||||
no longer carries it (contracts reference the revision they consumed —
|
||||
§17)."""
|
||||
p = _REGISTRY.get(pattern_id)
|
||||
if p is None:
|
||||
return None
|
||||
if revision is not None and p.revision != revision:
|
||||
raise ValueError(
|
||||
f"pattern {pattern_id} revision {revision} not in registry "
|
||||
f"(current: {p.revision}) — the consuming contract records which "
|
||||
f"revision it used"
|
||||
)
|
||||
return p
|
||||
@@ -0,0 +1,408 @@
|
||||
"""release_notes.py — evidence-derived Gitea release history.
|
||||
|
||||
SCAFFOLD-TRANCHE-009. Prime invariant (tranche brief):
|
||||
|
||||
Git records exact source history. Construction Contracts / maintenance
|
||||
items / tests / deployment records provide governed evidence. Gitea
|
||||
Releases provide the HUMAN-READABLE HISTORICAL ACCOUNT of what actually
|
||||
improved in each Rune release.
|
||||
|
||||
Release notes are evidence-derived history, not marketing prose:
|
||||
- facts come from governed records (closed contracts, resolved
|
||||
maintenance items, contract-quality records, observed evidence,
|
||||
lock/deploy artifacts) in that priority order;
|
||||
- commit messages alone never overrule governed evidence;
|
||||
- model prose may COMPRESS verified facts; it may not invent
|
||||
importance, completion, or maturity (§14 — this generator is fully
|
||||
deterministic; no model step exists here).
|
||||
|
||||
RELEASE SEMANTICS (§6, audited 2026-09-13):
|
||||
VERSION the semver in pyproject.toml / __init__ (e.g. 0.66.44)
|
||||
TAG v<VERSION>, created by auto-version on the BUMP commit
|
||||
release commit (tag SHA) the auto-bump commit — NOT necessarily the
|
||||
final state of the release
|
||||
converged deployed identity the last commit deployed under the same
|
||||
VERSION (same-version convergence commits are normal:
|
||||
closure/accounting/dependency-lock commits land and deploy
|
||||
after the bump without a version change)
|
||||
|
||||
The notes record BOTH: release_sha (tag target) and deployed_sha
|
||||
(converged deployment read from the deployment authority). The
|
||||
distinction is exposed honestly, never papered over (§6).
|
||||
|
||||
RELEASE RANGE for version N: (tag of previous version, tag N] PLUS
|
||||
same-version convergence commits up to the deployed identity (§9/§10).
|
||||
|
||||
PUBLICATION (§15): Gitea API POST /releases to create, PATCH /releases/{id}
|
||||
to update. Idempotent (§17): publishing twice for the same tag UPDATES the
|
||||
existing release — never duplicates. Notes carry provenance (§20-21):
|
||||
historical release documentation, NOT Memory and NOT structural authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"ReleaseNote",
|
||||
"extract_evidence_facts",
|
||||
"build_note",
|
||||
"render_markdown",
|
||||
"publish_release",
|
||||
"fetch_release",
|
||||
"converged_deployed_sha",
|
||||
]
|
||||
|
||||
# ── structured record (§13) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ReleaseNote:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
summary: str,
|
||||
improved: list[str] | None = None,
|
||||
added: list[str] | None = None,
|
||||
fixed: list[str] | None = None,
|
||||
proven: list[str] | None = None,
|
||||
limitations: list[str] | None = None,
|
||||
references: list[str] | None = None,
|
||||
release_sha: str = "",
|
||||
deployed_sha: str = "",
|
||||
provenance: list[str] | None = None,
|
||||
backfill: bool = False,
|
||||
):
|
||||
self.version = version
|
||||
self.summary = summary
|
||||
self.improved = sorted(improved or [])
|
||||
self.added = sorted(added or [])
|
||||
self.fixed = sorted(fixed or [])
|
||||
self.proven = sorted(proven or [])
|
||||
self.limitations = sorted(limitations or [])
|
||||
self.references = sorted(references or [])
|
||||
self.release_sha = release_sha
|
||||
self.deployed_sha = deployed_sha
|
||||
self.provenance = sorted(provenance or [])
|
||||
self.backfill = backfill
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"version": self.version,
|
||||
"summary": self.summary,
|
||||
"improved": self.improved,
|
||||
"added": self.added,
|
||||
"fixed": self.fixed,
|
||||
"proven": self.proven,
|
||||
"limitations": self.limitations,
|
||||
"references": self.references,
|
||||
"release_sha": self.release_sha,
|
||||
"deployed_sha": self.deployed_sha,
|
||||
"provenance": self.provenance,
|
||||
"backfill": self.backfill,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ReleaseNote":
|
||||
return cls(**d)
|
||||
|
||||
|
||||
def render_markdown(note: ReleaseNote) -> str:
|
||||
"""Human-readable Markdown (§7/§11): concise sections, empty sections
|
||||
omitted, evidence references kept, version/deployed distinction shown."""
|
||||
lines = [f"# Rune v{note.version}", "", note.summary, ""]
|
||||
if note.release_sha and note.deployed_sha:
|
||||
if note.release_sha[:12] == note.deployed_sha[:12]:
|
||||
lines.append(f"_Release commit: `{note.release_sha[:12]}` (deployed: `{note.deployed_sha[:12]}`)_")
|
||||
else:
|
||||
lines.append(
|
||||
f"_Tag commit: `{note.release_sha[:12]}` · "
|
||||
f"**final deployed: `{note.deployed_sha[:12]}`** "
|
||||
f"(same-version convergence commits included)_"
|
||||
)
|
||||
elif note.deployed_sha:
|
||||
lines.append(f"_Deployed: `{note.deployed_sha[:12]}`_")
|
||||
lines.append("")
|
||||
sections = [
|
||||
("Improved", note.improved),
|
||||
("Added", note.added),
|
||||
("Fixed", note.fixed),
|
||||
("Proven", note.proven),
|
||||
("Known limitations", note.limitations),
|
||||
("References", note.references),
|
||||
]
|
||||
for title, items in sections:
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"## {title}")
|
||||
for item in items:
|
||||
lines.append(f"- {item}")
|
||||
lines.append("")
|
||||
if note.backfill:
|
||||
lines.append("_Historical backfill: derived from surviving governed evidence after the fact._")
|
||||
if note.provenance:
|
||||
lines.append("_Derived from: " + "; ".join(note.provenance) + "_")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
# ── evidence extraction (§8/§24) ─────────────────────────────────────────────
|
||||
|
||||
_CONTRACT_EVIDENCE = "Construction Contract"
|
||||
_MAINT_EVIDENCE = "maintenance item"
|
||||
_DEPLOY_EVIDENCE = "deployment record"
|
||||
_LOCK_EVIDENCE = "dependency-lock record"
|
||||
|
||||
|
||||
def extract_evidence_facts(scaffold_doc: dict) -> dict:
|
||||
"""Deterministic fact extraction from governed records (§8). Returns the
|
||||
raw fact pool the note builder selects from. Commit messages are NOT a
|
||||
source here — governed records only."""
|
||||
facts: dict = {"improved": [], "added": [], "fixed": [], "proven": [], "limitations": [], "references": []}
|
||||
for e in scaffold_doc.get("entities", []):
|
||||
ident = e.get("identities", {})
|
||||
if e["kind"] == "construction_contract" and e.get("status") == "closed":
|
||||
facts["references"].append(f"{e['id']} — {ident.get('need', '')[:90]}")
|
||||
q = e.get("ext", {}).get("contract_quality", {})
|
||||
if q.get("correctly_predicted"):
|
||||
facts["proven"].append(
|
||||
f"{e['id']}: predicted effects independently confirmed ({len(q['correctly_predicted'])} entities)"
|
||||
)
|
||||
elif e["kind"] == "maintenance_item":
|
||||
status = ident.get("status")
|
||||
issue = ident.get("issue", "")[:90]
|
||||
if status == "resolved":
|
||||
facts["fixed"].append(f"{e['id']}: {issue}")
|
||||
elif status == "open":
|
||||
facts["limitations"].append(f"{e['id']}: {issue}")
|
||||
elif e["kind"] == "tui_projection_decision":
|
||||
# recorded projection decisions are release-relevant facts
|
||||
facts["references"].append(f"TUI projection for {ident.get('feature', '')}: {ident.get('projection', '')}")
|
||||
# contract-quality declared maturity evidence
|
||||
for e in scaffold_doc.get("entities", []):
|
||||
if e["kind"] == "construction_contract" and e.get("ext", {}).get("release_evidence"):
|
||||
rel = e["ext"]["release_evidence"]
|
||||
if isinstance(rel, dict) and rel.get("final_deployed") or rel.get("deployed_identity"):
|
||||
facts["proven"].append(
|
||||
f"{e['id']}: release chain proven ({rel.get('final_deployed') or rel.get('deployed_identity')})"
|
||||
)
|
||||
for k in facts:
|
||||
facts[k] = sorted(set(facts[k]))
|
||||
return facts
|
||||
|
||||
|
||||
def build_note(
|
||||
*,
|
||||
version: str,
|
||||
summary: str,
|
||||
scaffold_doc: dict,
|
||||
release_sha: str = "",
|
||||
deployed_sha: str = "",
|
||||
extra: dict | None = None,
|
||||
provenance: list[str] | None = None,
|
||||
backfill: bool = False,
|
||||
exclude_unsupported: list[str] | None = None,
|
||||
) -> ReleaseNote:
|
||||
"""Assemble a ReleaseNote from the governed fact pool + explicit extras
|
||||
(extra items must themselves be evidence-backed — the caller carries the
|
||||
responsibility). `exclude_unsupported` drops fact entries containing any
|
||||
of the given substrings — the §24F unsupported-claim rejection surface."""
|
||||
facts = extract_evidence_facts(scaffold_doc)
|
||||
if exclude_unsupported:
|
||||
|
||||
def _clean(items: list[str]) -> list[str]:
|
||||
return [i for i in items if not any(x.lower() in i.lower() for x in exclude_unsupported)]
|
||||
|
||||
for k in facts:
|
||||
facts[k] = _clean(facts[k])
|
||||
merged = {}
|
||||
for k in ("improved", "added", "fixed", "proven", "limitations", "references"):
|
||||
# §24F: extras pass through the SAME unsupported-claim filter as
|
||||
# extracted facts — a caller cannot smuggle a retracted claim in
|
||||
# via `extra` either.
|
||||
extras = [
|
||||
i for i in (extra or {}).get(k, []) if not any(x.lower() in i.lower() for x in (exclude_unsupported or []))
|
||||
]
|
||||
merged[k] = sorted(set(facts[k]) | set(extras))
|
||||
return ReleaseNote(
|
||||
version=version,
|
||||
summary=summary,
|
||||
release_sha=release_sha,
|
||||
deployed_sha=deployed_sha,
|
||||
provenance=provenance or [],
|
||||
backfill=backfill,
|
||||
**merged,
|
||||
)
|
||||
|
||||
|
||||
# ── Gitea publication (§15/§17/§25) ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _api(url: str, token: str, method: str = "GET", payload: dict | None = None) -> tuple[int, dict]:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
method=method,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
data=json.dumps(payload).encode() if payload else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
body = r.read()
|
||||
return r.status, (json.loads(body) if body else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
text = body.decode("utf-8", "replace")
|
||||
try:
|
||||
return e.code, (json.loads(text) if text else {})
|
||||
except ValueError:
|
||||
# Gitea returns plain-text bodies for some errors (e.g. 404
|
||||
# "page not found" on unknown release-tag lookups)
|
||||
return e.code, {"_raw": text[:200]}
|
||||
except OSError as e:
|
||||
return 0, {"_error": str(e)[:200]}
|
||||
|
||||
|
||||
def fetch_release(server: str, repo: str, token: str, tag: str) -> dict | None:
|
||||
status, data = _api(f"{server}/api/v1/repos/{repo}/releases/tags/{tag}", token)
|
||||
return data if status == 200 else None
|
||||
|
||||
|
||||
def publish_release(server: str, repo: str, token: str, note: ReleaseNote, *, target_commitish: str = "master") -> dict:
|
||||
"""Create or idempotently update the Gitea release for note.version
|
||||
(§17): existing release → PATCH body (same evidence converges, never
|
||||
duplicates); missing release → POST create with the tag target. The tag
|
||||
itself is owned by auto-version — this module never rewrites tags (§30.5).
|
||||
Returns {action: created|updated, release_id, html_url?} or an error
|
||||
dict with `failed: True` (§16 — publication failure is visible)."""
|
||||
tag = f"v{note.version}"
|
||||
body = render_markdown(note)
|
||||
existing = fetch_release(server, repo, token, tag)
|
||||
if existing is not None:
|
||||
status, data = _api(
|
||||
f"{server}/api/v1/repos/{repo}/releases/{existing['id']}",
|
||||
token,
|
||||
"PATCH",
|
||||
{"body": body, "name": f"Rune v{note.version}"},
|
||||
)
|
||||
if status != 200:
|
||||
return {
|
||||
"failed": True,
|
||||
"action": "update",
|
||||
"status": status,
|
||||
"error": data.get("_error") or data.get("message", "")[:200],
|
||||
}
|
||||
return {"action": "updated", "release_id": data.get("id"), "html_url": data.get("html_url", "")}
|
||||
status, data = _api(
|
||||
f"{server}/api/v1/repos/{repo}/releases",
|
||||
token,
|
||||
"POST",
|
||||
{
|
||||
"tag_name": tag,
|
||||
"target_commitish": target_commitish,
|
||||
"name": f"Rune v{note.version}",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
},
|
||||
)
|
||||
if status not in (200, 201):
|
||||
return {
|
||||
"failed": True,
|
||||
"action": "create",
|
||||
"status": status,
|
||||
"error": data.get("_error") or data.get("message", "")[:200],
|
||||
}
|
||||
return {"action": "created", "release_id": data.get("id"), "html_url": data.get("html_url", "")}
|
||||
|
||||
|
||||
def converged_deployed_sha(deploy_root: str = "/mnt/workdrive/rune-code") -> str:
|
||||
"""Read the converged deployed commit from the deployment authority
|
||||
(the `current` symlink's build identity) — never assumed equal to the
|
||||
tag SHA (§6)."""
|
||||
try:
|
||||
target = Path(os_readlink(Path(deploy_root) / "current"))
|
||||
identity = json.loads((target / "src" / "rune" / "_build_identity.json").read_text(encoding="utf-8"))
|
||||
return str(identity.get("commit", ""))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def os_readlink(p: Path) -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.readlink(p))
|
||||
|
||||
|
||||
# ── automatic lifecycle entry points (Tranche 9B §4/§7) ─────────────────────
|
||||
|
||||
def auto_initial_note(*, version: str, summary: str, scaffold_store: str,
|
||||
release_sha: str, deployed_sha: str = "",
|
||||
provenance: list[str] | None = None) -> ReleaseNote:
|
||||
"""RELEASE INITIALIZATION: build evidence-derived notes with NO
|
||||
hand-written extra prose (§7). Facts come deterministically from the
|
||||
governed scaffold store — contracts closed, maintenance resolved/open,
|
||||
quality records. Routine releases require nothing from the builder."""
|
||||
try:
|
||||
doc = json.loads(Path(scaffold_store).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
doc = {"entities": [], "relationships": [], "observations": []}
|
||||
facts = extract_evidence_facts(doc)
|
||||
return ReleaseNote(
|
||||
version=version, summary=summary,
|
||||
improved=[], added=[], fixed=[],
|
||||
proven=facts["proven"], limitations=facts["limitations"],
|
||||
references=facts["references"],
|
||||
release_sha=release_sha, deployed_sha=deployed_sha,
|
||||
provenance=provenance or ["automatic release initialization "
|
||||
"(auto-version workflow)"])
|
||||
|
||||
|
||||
def convergence_refresh(existing_note: ReleaseNote, *,
|
||||
deployed_sha: str,
|
||||
scaffold_store: str,
|
||||
extra_provenance: str = "") -> ReleaseNote:
|
||||
"""RELEASE CONVERGENCE REFRESH: a later SHA of the SAME version became
|
||||
deployment:current — refresh the SAME note with the new deployed
|
||||
identity and any newly-governed facts (§3/§10). Tag/release SHA never
|
||||
changes; evidence accumulates; idempotent by construction (same inputs
|
||||
→ same note)."""
|
||||
try:
|
||||
doc = json.loads(Path(scaffold_store).read_text(encoding="utf-8"))
|
||||
facts = extract_evidence_facts(doc)
|
||||
except (OSError, ValueError):
|
||||
facts = {"proven": [], "limitations": [], "references": []}
|
||||
prov = list(existing_note.provenance)
|
||||
if extra_provenance and extra_provenance not in prov:
|
||||
prov.append(extra_provenance)
|
||||
return ReleaseNote(
|
||||
version=existing_note.version, summary=existing_note.summary,
|
||||
improved=existing_note.improved, added=existing_note.added,
|
||||
fixed=existing_note.fixed,
|
||||
proven=sorted(set(existing_note.proven) | set(facts["proven"])),
|
||||
limitations=sorted(set(existing_note.limitations)
|
||||
| set(facts["limitations"])),
|
||||
references=sorted(set(existing_note.references)
|
||||
| set(facts["references"])),
|
||||
release_sha=existing_note.release_sha, # tag SHA NEVER changes
|
||||
deployed_sha=deployed_sha, # convergence truth
|
||||
provenance=prov, backfill=existing_note.backfill)
|
||||
|
||||
|
||||
def build_from_deployment(*, version: str, deployed_sha: str,
|
||||
scaffold_store: str,
|
||||
release_sha: str = "",
|
||||
summary: str = "") -> ReleaseNote:
|
||||
"""Deploy-side convergence entry: rebuild/refresh notes from the
|
||||
CURRENT governed store + the deployment authority's identity. No
|
||||
hand-written prose required."""
|
||||
note = auto_initial_note(
|
||||
version=version,
|
||||
summary=summary or f"Rune v{version} — evidence-derived release "
|
||||
f"history (converged deployment)",
|
||||
scaffold_store=scaffold_store, release_sha=release_sha,
|
||||
deployed_sha=deployed_sha,
|
||||
provenance=["automatic convergence refresh (deploy workflow)"])
|
||||
return note
|
||||
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.66.35
|
||||
Version: v0.66.48
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1856,7 +1856,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.66.35 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.66.48 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
+185
-24
@@ -120,6 +120,39 @@ class Scaffold:
|
||||
def save(self, doc: dict) -> Path:
|
||||
return save(doc, self.current)
|
||||
|
||||
def save_verified(self, doc: dict, selector, expect, *, what: str = "state") -> Path:
|
||||
"""Save + RELOAD-READBACK confirmation (SCAFFOLD-TRANCHE-006 §22A).
|
||||
Catches DISK-WRITE failures: the in-memory doc is saved, then the
|
||||
store is reloaded from disk and checked. This confirms the write
|
||||
landed — it cannot catch a mutation that was never saved, because
|
||||
saving is what this method does. For the forgotten-save check use
|
||||
verify_on_disk (no write)."""
|
||||
p = self.save(doc)
|
||||
reloaded = self.load()
|
||||
value = selector(reloaded)
|
||||
ok = expect(value) if callable(expect) else value == expect
|
||||
if not ok:
|
||||
raise AssertionError(
|
||||
f"persistence verification FAILED for {what!r}: reloaded "
|
||||
f"store does not reflect the saved doc (disk-write failure)")
|
||||
return p
|
||||
|
||||
def verify_on_disk(self, selector, expect, *, what: str = "state") -> bool:
|
||||
"""PURE READBACK guard (SCAFFOLD-TRANCHE-006 §22A, the actual
|
||||
Tranche 5 defect guard): reload the store from disk WITHOUT saving
|
||||
and check the expectation. An in-memory mutation that was never
|
||||
persisted fails here — the disk state is what a future session
|
||||
would see. This is the forgotten-save lie detector."""
|
||||
reloaded = self.load()
|
||||
value = selector(reloaded)
|
||||
ok = expect(value) if callable(expect) else value == expect
|
||||
if not ok:
|
||||
raise AssertionError(
|
||||
f"durable-state verification FAILED for {what!r}: the on-"
|
||||
f"disk store does not reflect the claimed mutation — it was "
|
||||
f"probably never saved (Tranche 5 defect class A)")
|
||||
return True
|
||||
|
||||
# -- project structure (open-world kinds; no fixed vocabulary) ----------
|
||||
|
||||
def add_domain(self, doc: dict, name: str, description: str = "") -> dict:
|
||||
@@ -331,6 +364,7 @@ class Scaffold:
|
||||
regression_obligations: list[str] | None = None,
|
||||
verification_obligations: list[str] | None = None,
|
||||
migration_obligations: list[str] | None = None,
|
||||
expected_new_entities: list[str] | None = None,
|
||||
rollback_ref: str = "",
|
||||
acceptance: list[str] | None = None,
|
||||
non_goals: list[str] | None = None,
|
||||
@@ -338,7 +372,11 @@ class Scaffold:
|
||||
"""Create a Construction Contract in `draft` phase. One durable
|
||||
object; phases/status are plain fields with a legal-transition map.
|
||||
`toolchain` is a data seam: language/runtime/framework/libs/rationale
|
||||
— no Python-specific assumptions enforced."""
|
||||
— no Python-specific assumptions enforced.
|
||||
`expected_new_entities` (SCAFFOLD-TRANCHE-004 contract-quality
|
||||
repair): entities this build PLANS to create. At closure these are
|
||||
checked against observed reality — an observed planned-new entity is
|
||||
NOT a prediction miss (the Tranche 3 defect this field fixes)."""
|
||||
if entity(doc, f"contract:{contract_id}") is not None:
|
||||
raise ValueError(f"contract already exists: {contract_id}")
|
||||
identities = {
|
||||
@@ -352,6 +390,7 @@ class Scaffold:
|
||||
"toolchain": dict(toolchain or {}), # data seam: any language/runtime
|
||||
"affected_entities": sorted(affected_entities or []),
|
||||
"expected_consumers": sorted(expected_consumers or []),
|
||||
"expected_new_entities": sorted(expected_new_entities or []),
|
||||
"regression_obligations": sorted(regression_obligations or []),
|
||||
"verification_obligations": sorted(verification_obligations or []),
|
||||
"migration_obligations": sorted(migration_obligations or []),
|
||||
@@ -432,7 +471,25 @@ class Scaffold:
|
||||
"""Close a contract: record observed impact independently, run the
|
||||
predicted-vs-observed comparison, persist Contract Quality evidence.
|
||||
Requires the contract to be in `superseding` phase (the final work
|
||||
phase) or `verifying` (pure-observation builds)."""
|
||||
phase) or `verifying` (pure-observation builds).
|
||||
|
||||
SCAFFOLD-TRANCHE-004 contract-quality repair (§16-18): closure
|
||||
distinguishes
|
||||
predicted_existing_affected — planned changes to EXISTING entities
|
||||
expected_new — entities the build PLANNED to create
|
||||
observed_existing — predicted entities evidence confirms
|
||||
observed_new — newly observed surfaces
|
||||
correctly_predicted — predicted existing AND observed
|
||||
predicted_but_unaffected — predicted existing, NOT observed
|
||||
expected_new_observed — planned creation happened
|
||||
expected_new_missing — planned creation did NOT happen
|
||||
genuinely_unexpected_new — observed, planned by NEITHER list
|
||||
Prediction and observation remain independently sourced: the
|
||||
predicted sets come from the approval-time frontier/contract fields;
|
||||
the observed set comes ONLY from the caller's evidence items
|
||||
(git/tests/build/deploy) — never from re-deriving the prediction.
|
||||
Legacy keys preserved: `missed` aliases genuinely_unexpected_new,
|
||||
`needless` aliases predicted_but_unaffected (old contracts readable)."""
|
||||
e = entity(doc, f"contract:{contract_id}")
|
||||
if e is None:
|
||||
raise ValueError(f"contract not found: {contract_id}")
|
||||
@@ -440,33 +497,70 @@ class Scaffold:
|
||||
raise IllegalPhaseTransition(f"close requires superseding/verifying phase, got {e.get('phase')}")
|
||||
predicted = e.get("ext", {}).get("predicted_frontier")
|
||||
affected = e.get("identities", {}).get("affected_entities", [])
|
||||
expected_new = set(e.get("identities", {}).get("expected_new_entities", []))
|
||||
# Tranche 5 §14: expected RUNTIME effects (deployment:current advances
|
||||
# etc.) are prediction/accounting metadata, not workflow steps. An
|
||||
# observed entity matching an expected runtime effect is EXPECTED,
|
||||
# not genuinely unexpected — but only when independently observed
|
||||
# (§15: prediction never self-certifies; the evidence item must exist).
|
||||
expected_runtime = set(e.get("ext", {}).get("expected_runtime_effects", []))
|
||||
quality: dict = {"contract": contract_id, "captured_at": now()}
|
||||
if predicted is not None and affected:
|
||||
# observed from the caller's independent evidence items, not from
|
||||
# re-running the frontier on the same graph the prediction used
|
||||
obs = record_observed_impact(doc, contract_id, observed_evidence or [])
|
||||
pred_set = {
|
||||
|
||||
# independent observed impact (§17): evidence items only
|
||||
obs = record_observed_impact(doc, contract_id, observed_evidence or [])
|
||||
obs_set = set(obs["observed_entities"])
|
||||
|
||||
pred_existing = set(e.get("ext", {}).get("predicted_existing_affected", []))
|
||||
pred_existing |= set(affected)
|
||||
if predicted is not None:
|
||||
pred_existing |= {
|
||||
x
|
||||
for k in ("level0_direct", "level1_obligations", "contracts", "level2_verification")
|
||||
for x in predicted.get(k, [])
|
||||
}
|
||||
obs_set = set(obs["observed_entities"])
|
||||
quality.update(
|
||||
{
|
||||
"predicted": sorted(pred_set),
|
||||
"observed": sorted(obs_set),
|
||||
"missed": sorted(obs_set - pred_set),
|
||||
"needless": sorted(pred_set - obs_set),
|
||||
"candidate_corrections": [
|
||||
{
|
||||
"entity": x,
|
||||
"status": "CANDIDATE",
|
||||
"note": "missed by prediction — evidence required before store update",
|
||||
}
|
||||
for x in sorted(obs_set - pred_set)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
correctly_predicted = sorted(pred_existing & obs_set)
|
||||
predicted_but_unaffected = sorted(pred_existing - obs_set)
|
||||
expected_new_observed = sorted(expected_new & obs_set)
|
||||
expected_new_missing = sorted(expected_new - obs_set)
|
||||
# effect strings are phrases ("deployment:current advances"); an
|
||||
# observed entity matches when an expected effect STARTS with its id
|
||||
matched_runtime = {o for o in obs_set if any(eff.startswith(o) for eff in expected_runtime)}
|
||||
expected_runtime_observed = sorted(
|
||||
eff for eff in expected_runtime if any(eff.startswith(o) for o in matched_runtime)
|
||||
)
|
||||
expected_runtime_missing = sorted(expected_runtime - set(expected_runtime_observed))
|
||||
genuinely_unexpected_new = sorted(obs_set - pred_existing - expected_new - matched_runtime)
|
||||
|
||||
quality.update(
|
||||
{
|
||||
"predicted_existing_affected": sorted(pred_existing),
|
||||
"expected_new_entities": sorted(expected_new),
|
||||
"observed": sorted(obs_set), # legacy union (Tranche 2 readers)
|
||||
"observed_existing": correctly_predicted,
|
||||
"observed_new": sorted(obs_set & (expected_new | (obs_set - pred_existing))),
|
||||
"correctly_predicted": correctly_predicted,
|
||||
"predicted_but_unaffected": predicted_but_unaffected,
|
||||
"expected_new_observed": expected_new_observed,
|
||||
"expected_new_missing": expected_new_missing,
|
||||
"expected_runtime_effects": sorted(expected_runtime),
|
||||
"expected_runtime_observed": expected_runtime_observed,
|
||||
"expected_runtime_missing": sorted(expected_runtime - obs_set),
|
||||
"genuinely_unexpected_new": genuinely_unexpected_new,
|
||||
# legacy aliases (Tranche 2/3 consumers)
|
||||
"missed": genuinely_unexpected_new,
|
||||
"needless": predicted_but_unaffected,
|
||||
"candidate_corrections": [
|
||||
{
|
||||
"entity": x,
|
||||
"status": "CANDIDATE",
|
||||
"note": "genuinely unexpected new surface — evidence required before store update",
|
||||
}
|
||||
for x in genuinely_unexpected_new
|
||||
],
|
||||
"evidence_sources": sorted({item.get("source", "unattributed") for item in (observed_evidence or [])}),
|
||||
}
|
||||
)
|
||||
if scope_files is not None:
|
||||
quality["scope_check"] = self.check_scope(doc, contract_id, scope_files)
|
||||
e.setdefault("ext", {})["contract_quality"] = quality
|
||||
@@ -515,6 +609,73 @@ class Scaffold:
|
||||
doc["observations"].append({"kind": "fact_staled", **s, "observed_at": now()})
|
||||
return {"staled": staled}
|
||||
|
||||
# -- layout obligation injection (SCAFFOLD-TRANCHE-004 §19-22) -------------
|
||||
|
||||
def layout_obligations(self, doc: dict, affected: list[str]) -> list[dict]:
|
||||
"""Query Layout Contracts relevant to a change's affected surface and
|
||||
emit build obligations the generated contract MUST inherit (§19).
|
||||
|
||||
`affected` may contain: `ui:<component>` ids, implementation paths
|
||||
(matched against contract identities.implementation), or region ids
|
||||
(a change to a region touches everything bound to it).
|
||||
|
||||
Semantics:
|
||||
FLEXIBLE contract → preserve/verify current contracted placement;
|
||||
movable via authorized amendment only.
|
||||
USER-LOCKED → placement is USER AUTHORITY: the build must
|
||||
preserve it, and the obligation states that
|
||||
changing it requires explicit user
|
||||
authorization (amend_layout_contract with
|
||||
authorized_by='user'). The live path NEVER
|
||||
reconciles user-locked drift by editing the
|
||||
contract (§20) — code adapts instead.
|
||||
No applicable contract → [] (unrelated backend changes inherit
|
||||
no layout obligations — tested).
|
||||
"""
|
||||
affected_set = set(affected)
|
||||
obligations = []
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "layout_contract" or e.get("status") in ("RETIRED",):
|
||||
continue
|
||||
ident = e["identities"]
|
||||
comp = ident.get("component", "")
|
||||
region = ident.get("region", "")
|
||||
impl = ident.get("implementation", "")
|
||||
region_hit = f"region:{region}" in affected_set
|
||||
comp_hit = comp in affected_set
|
||||
impl_hit = bool(impl) and any(impl.startswith(a) or a in impl for a in affected_set if a.endswith(".py"))
|
||||
if not (comp_hit or region_hit or impl_hit):
|
||||
continue
|
||||
locked = bool(ident.get("lock"))
|
||||
provenance = ident.get("provenance", "scaffold")
|
||||
if locked:
|
||||
obligation = (
|
||||
f"USER-LOCKED: {comp} placement in region '{region}' is "
|
||||
f"user authority — preserve it exactly; changing requires "
|
||||
f"explicit user authorization "
|
||||
f"(amend_layout_contract authorized_by='user')"
|
||||
)
|
||||
else:
|
||||
obligation = (
|
||||
f"preserve/verify {comp} contracted placement "
|
||||
f"(region '{region}', provenance {provenance}); movable "
|
||||
f"only via authorized layout amendment"
|
||||
)
|
||||
obligations.append(
|
||||
{
|
||||
"component": comp,
|
||||
"region": region,
|
||||
"lock": locked,
|
||||
"provenance": provenance,
|
||||
"obligation": obligation,
|
||||
"verification": "layout_contract_check must PASS"
|
||||
if not locked
|
||||
else "layout_contract_check must PASS; USER-LOCKED placement unchanged",
|
||||
"status": e.get("status", "ACTIVE"),
|
||||
}
|
||||
)
|
||||
return obligations
|
||||
|
||||
# -- bridge to Rune Spine (explicit, non-duplicating) ---------------------
|
||||
|
||||
def spine_bridge_entity(self) -> dict:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""tui_backlog.py — durable TUI Upgrade Backlog (SCAFFOLD-TRANCHE-007 §22-25).
|
||||
|
||||
Standing architectural requirement: every meaningful backend feature is
|
||||
checked for whether it needs a TUI/user-surface projection or operator
|
||||
control. The backlog records that work so the user-facing system never
|
||||
falls behind the backend.
|
||||
|
||||
AUTHORITY (§25): the backlog is a maintenance/product-surface LEDGER —
|
||||
outstanding work only. It is NOT TUI structural authority. Implemented TUI
|
||||
structure remains under Layout Contracts / the project's structural
|
||||
authority.
|
||||
|
||||
Projection types (§24): NONE / INSPECTION / CONTROL / BOTH / UNKNOWN.
|
||||
Auto-capture seam: construction-contract closure can ask "TUI projection
|
||||
required?" — recorded truthfully even when the answer is NONE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rune.structural_store import add_entity, entity, now
|
||||
|
||||
__all__ = [
|
||||
"add_backlog_item",
|
||||
"get_backlog_item",
|
||||
"list_backlog",
|
||||
"update_backlog_status",
|
||||
"record_tui_projection_decision",
|
||||
"PROJECTIONS",
|
||||
"STATUSES",
|
||||
]
|
||||
|
||||
PROJECTIONS = {"NONE", "INSPECTION", "CONTROL", "BOTH", "UNKNOWN"}
|
||||
STATUSES = {"OPEN", "PLANNED", "IN_PROGRESS", "DONE", "WONT_DO"}
|
||||
|
||||
|
||||
def add_backlog_item(
|
||||
doc: dict,
|
||||
item_id: str,
|
||||
*,
|
||||
feature: str,
|
||||
desired_behavior: str,
|
||||
why: str,
|
||||
projection: str = "INSPECTION",
|
||||
dependencies: list[str] | None = None,
|
||||
priority: str = "normal",
|
||||
status: str = "OPEN",
|
||||
authority_source: str = "product-surface requirement",
|
||||
evidence_refs: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Add/refresh one backlog item (§22). `feature` (originating subsystem)
|
||||
and `projection` classification are retained permanently. Duplicate ids
|
||||
refresh in place — the ledger does not fork."""
|
||||
if projection not in PROJECTIONS:
|
||||
raise ValueError(f"projection must be one of {sorted(PROJECTIONS)}")
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"status must be one of {sorted(STATUSES)}")
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"tui-backlog:{item_id}",
|
||||
kind="tui_backlog_item",
|
||||
identities={
|
||||
"feature": feature,
|
||||
"desired_behavior": desired_behavior,
|
||||
"why": why,
|
||||
"projection": projection,
|
||||
"dependencies": sorted(dependencies or []),
|
||||
"priority": priority,
|
||||
"status": status,
|
||||
"authority_source": authority_source,
|
||||
},
|
||||
evidence_refs=evidence_refs or [f"backlog-added:{authority_source}"],
|
||||
)
|
||||
|
||||
|
||||
def get_backlog_item(doc: dict, item_id: str) -> dict | None:
|
||||
return entity(doc, f"tui-backlog:{item_id}")
|
||||
|
||||
|
||||
def list_backlog(doc: dict, *, status: str | None = None, projection: str | None = None) -> list[dict]:
|
||||
out = []
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] != "tui_backlog_item":
|
||||
continue
|
||||
if status and e["identities"].get("status") != status:
|
||||
continue
|
||||
if projection and e["identities"].get("projection") != projection:
|
||||
continue
|
||||
out.append(e)
|
||||
return sorted(out, key=lambda e: e["id"])
|
||||
|
||||
|
||||
def update_backlog_status(doc: dict, item_id: str, status: str, *, evidence_ref: str = "") -> dict:
|
||||
e = get_backlog_item(doc, item_id)
|
||||
if e is None:
|
||||
raise ValueError(f"no backlog item: {item_id}")
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"status must be one of {sorted(STATUSES)}")
|
||||
e["identities"]["status"] = status
|
||||
e["observed_at"] = now()
|
||||
if evidence_ref:
|
||||
e["evidence_refs"] = sorted(set(e.get("evidence_refs", [])) | {evidence_ref})
|
||||
return e
|
||||
|
||||
|
||||
def record_tui_projection_decision(
|
||||
doc: dict, feature_id: str, *, projection: str, rationale: str, source_contract: str = ""
|
||||
) -> dict:
|
||||
"""§24 AUTO-CAPTURE SEAM: record a "TUI projection required?" decision
|
||||
for a backend feature — including a truthful NONE. This is accounting,
|
||||
not UI design. The seam exists so future contract closures can call it;
|
||||
nothing is auto-designed."""
|
||||
if projection not in PROJECTIONS:
|
||||
raise ValueError(f"projection must be one of {sorted(PROJECTIONS)}")
|
||||
return add_entity(
|
||||
doc,
|
||||
id=f"tui-projection:{feature_id}",
|
||||
kind="tui_projection_decision",
|
||||
identities={
|
||||
"feature": feature_id,
|
||||
"projection": projection,
|
||||
"rationale": rationale[:200],
|
||||
"source_contract": source_contract,
|
||||
},
|
||||
evidence_refs=[f"tui-projection-decision:{projection}"],
|
||||
)
|
||||
+35796
-4
File diff suppressed because it is too large.
Load diff
+826
-121
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,375 @@
|
||||
"""test_construction_decision.py — SCAFFOLD-TRANCHE-004 §29 test plan (A-M)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import capability_fit as cf # noqa: E402
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import toolchain as tc # noqa: E402
|
||||
|
||||
|
||||
# ── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _spine_doc():
|
||||
"""Rune-Spine-like authority with an existing export capability."""
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(
|
||||
doc, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["fixture"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["fixture"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["fixture"]
|
||||
)
|
||||
ss.add_entity(doc, id="module:cli", kind="module", identities={}, evidence_refs=["fixture"])
|
||||
ss.add_relationship(
|
||||
doc, type="consumes", from_id="module:cli", to_id="capability:exporter", evidence_refs=["fixture"]
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def _need(**kw):
|
||||
defaults = dict(
|
||||
need_id="need-001",
|
||||
objective="persistent project export",
|
||||
origin="user",
|
||||
project="rune",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=[],
|
||||
evidence_refs=["user-request"],
|
||||
approval_context="covered by SCAFFOLD-TRANCHE-004",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return cd.Need(**defaults)
|
||||
|
||||
|
||||
# ── A. Need → CapabilityRequirement normalization ───────────────────────────
|
||||
|
||||
|
||||
def test_a_normalization():
|
||||
req, missing = cd.normalize_need(_need())
|
||||
assert req.objective == "persistent project export"
|
||||
assert "contract:csv_export" in req.contracts
|
||||
assert req.props["source_need"] == "need-001"
|
||||
assert req.props["task_ref"] == ""
|
||||
|
||||
|
||||
def test_a_normalization_preserves_provenance_and_flags_gaps():
|
||||
need = _need(evidence_refs=[], constraints={})
|
||||
req, missing = cd.normalize_need(need)
|
||||
assert req.props["source_need"] == "need-001"
|
||||
assert "evidence_refs" in missing # recorded, not invented
|
||||
|
||||
|
||||
# ── B. Need references Task/Intent without duplicating authority ───────────
|
||||
|
||||
|
||||
def test_b_need_references_task_authority():
|
||||
need = _need(task_ref="intent:EXPR-42")
|
||||
d = need.to_dict()
|
||||
assert d["task_ref"] == "intent:EXPR-42"
|
||||
# the Need carries a REFERENCE — no task status/state fields of its own
|
||||
assert "status" not in d and "state" not in d and "tasks" not in d
|
||||
|
||||
|
||||
# ── C. structural discovery invoked automatically ───────────────────────────
|
||||
|
||||
|
||||
def test_c_live_discovery():
|
||||
trace = cd.make_decision(_need(), authorities={"rune_spine": _spine_doc()})
|
||||
assert any(c["id"] == "capability:exporter" for c in trace["candidates"])
|
||||
assert trace["candidates"][0]["authority"] == "rune_spine"
|
||||
|
||||
|
||||
# ── D. exact REUSE path ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_d_reuse_path():
|
||||
trace = cd.make_decision(_need(), authorities={"rune_spine": _spine_doc()})
|
||||
assert trace["resolution"] == "REUSE"
|
||||
assert trace["research"]["route"] == "NONE"
|
||||
|
||||
|
||||
# ── E. ADAPT_EXISTING path (with auto regression obligations) ───────────────
|
||||
|
||||
|
||||
def test_e_adapt_path_auto_regression():
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(
|
||||
doc, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="module:cli", kind="module", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(doc, type="consumes", from_id="module:cli", to_id="capability:exporter", evidence_refs=["f"])
|
||||
need = _need(evidence_refs=["x"])
|
||||
need.constraints["contracts"] = ["contract:csv_export", "contract:encrypted_export"]
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "ADAPT_EXISTING"
|
||||
# BUILD-BUT-DON'T-BREAK obligations generated AUTOMATICALLY (§9):
|
||||
reg = " ".join(trace["obligations"]["regression"])
|
||||
assert "preserve existing behavior of capability:exporter" in reg
|
||||
assert "module:cli" in reg
|
||||
|
||||
|
||||
# ── F. COMPOSE path ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_f_compose_path():
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(doc, id="capability:a", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
ss.add_entity(doc, id="capability:b", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
for c in ("contract:streaming", "contract:bounded_memory"):
|
||||
ss.add_entity(doc, id=c, kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(doc, type="implements", from_id="capability:a", to_id="contract:streaming", evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:b", to_id="contract:bounded_memory", evidence_refs=["f"]
|
||||
)
|
||||
need = _need(
|
||||
objective="stream with bounded memory",
|
||||
constraints={"contracts": ["contract:streaming", "contract:bounded_memory"]},
|
||||
evidence_refs=["x"],
|
||||
)
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "COMPOSE"
|
||||
assert set(trace["obligations"]["composition"][0]["components"]) == {"capability:a", "capability:b"}
|
||||
assert "temporary" in trace["obligations"]["composition"][0]["permanence"]
|
||||
|
||||
|
||||
# ── G. ACQUIRE_EXTERNAL routes to research seam ─────────────────────────────
|
||||
|
||||
|
||||
def test_g_acquire_routes_to_research():
|
||||
trace = cd.make_decision(
|
||||
_need(objective="quantum error correction", constraints={"contracts": ["contract:qec"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": ss.new_doc()},
|
||||
)
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE" # no candidates at all
|
||||
trace2 = cd.make_decision(
|
||||
_need(objective="quantum error correction", constraints={"contracts": ["contract:qec"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
)
|
||||
assert trace2["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
# external evidence → ACQUIRE + full research route recorded (seam)
|
||||
doc = _spine_doc()
|
||||
ss.add_entity(doc, id="capability:x", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
trace3 = cd.make_decision(
|
||||
_need(objective="qec", constraints={"contracts": ["contract:qec"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": doc},
|
||||
)
|
||||
assert trace3["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert trace3["research"]["status"] == "RECORDED_NOT_EXECUTED" or trace3["research"]["route"] in (
|
||||
"NONE",
|
||||
"TARGETED_EVIDENCE_PROBE",
|
||||
)
|
||||
|
||||
|
||||
def test_g_acquire_with_external_evidence():
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(doc, id="capability:p", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
# candidate provides nothing required → all incompatible
|
||||
res = cf.decide(
|
||||
doc,
|
||||
cd.normalize_need(_need())[0],
|
||||
[
|
||||
cf.evaluate_fit(
|
||||
cd.normalize_need(_need())[0],
|
||||
{
|
||||
"id": "capability:p",
|
||||
"kind": "capability",
|
||||
"status": "CURRENT",
|
||||
"superseded_by": None,
|
||||
"provides": ["contract:other"],
|
||||
"consumers": [],
|
||||
"maturity": "WIRED",
|
||||
"evidence_refs": [],
|
||||
"identities": {},
|
||||
},
|
||||
)
|
||||
],
|
||||
external_evidence=[{"tool": "libcsv", "license": "MIT"}],
|
||||
)
|
||||
assert res["resolution"] == "ACQUIRE_EXTERNAL"
|
||||
|
||||
|
||||
# ── H/I. INSUFFICIENT_EVIDENCE never becomes BUILD_NEW ──────────────────────
|
||||
|
||||
|
||||
def test_h_insufficient_never_build_new():
|
||||
trace = cd.make_decision(
|
||||
_need(objective="impossible thing", constraints={"contracts": ["contract:nope"]}, evidence_refs=["x"]),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
)
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_i_build_new_requires_positive_evidence_everywhere():
|
||||
doc1 = _spine_doc() # has partials → ADAPT/COMPOSE territory
|
||||
doc2 = ss.new_doc() # nothing at all → INSUFFICIENT
|
||||
need = _need(
|
||||
objective="odd requirement",
|
||||
constraints={"contracts": ["contract:csv_export", "contract:never_seen"]},
|
||||
evidence_refs=["x"],
|
||||
)
|
||||
t1 = cd.make_decision(need, authorities={"rune_spine": doc1})
|
||||
assert t1["resolution"] != "BUILD_NEW" # csv_export partial exists → adapt
|
||||
t2 = cd.make_decision(need, authorities={"rune_spine": doc1, "project_scaffold": doc2})
|
||||
assert t2["resolution"] == "ADAPT_EXISTING" # merge: concrete beats insufficient
|
||||
# BUILD_NEW only via brittle-coupling positive evidence (unit-covered in
|
||||
# test_capability_fit) — merge never upgrades insufficient to build
|
||||
|
||||
|
||||
def test_i_normalization_gap_blocks_build_new():
|
||||
doc = ss.new_doc()
|
||||
# a partial fit exists; normalization is incomplete
|
||||
ss.add_entity(doc, id="capability:p", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"])
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:p", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:missing_half", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:p", to_id="contract:missing_half", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:unverifiable", kind="contract", identities={}, evidence_refs=["f"])
|
||||
need = _need(
|
||||
objective="x", constraints={"contracts": ["contract:unverifiable"]}, evidence_refs=[]
|
||||
) # no evidence refs → normalization gap
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert "normalization gaps recorded, not invented" in trace["rationale"]
|
||||
|
||||
|
||||
# ── J/K. toolchain decision enters the trace ────────────────────────────────
|
||||
|
||||
|
||||
def test_j_toolchain_in_trace():
|
||||
trace = cd.make_decision(
|
||||
_need(), authorities={"rune_spine": _spine_doc()}, project_toolchain={"existing_language": "python"}
|
||||
)
|
||||
assert trace["toolchain"]["selected_language"] == "python"
|
||||
assert trace["toolchain"]["resolution"] == "KEEP_EXISTING"
|
||||
|
||||
|
||||
def test_k_material_toolchain_change_marks_approval():
|
||||
trace = cd.make_decision(
|
||||
_need(),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
toolchain_constraints=tc.ToolchainConstraints(existing_language="python"),
|
||||
)
|
||||
# simulate a requested change via direct resolver check (the live path
|
||||
# records the toolchain decision; a change request surfaces approval)
|
||||
d = tc.resolve_toolchain(tc.ToolchainConstraints(existing_language="python"), requested_language="rust")
|
||||
assert d.user_approval_required is True
|
||||
# and the approval gate honors it:
|
||||
gate = cd.needs_user_approval("REUSE", d, [], _need(approval_context=""))
|
||||
assert gate["required"] is True
|
||||
assert "toolchain change" in gate["reasons"][0]
|
||||
|
||||
|
||||
# ── L. approved bounded maintenance does not repeatedly ask ─────────────────
|
||||
|
||||
|
||||
def test_l_bounded_work_inside_approved_contract_does_not_reask():
|
||||
gate = cd.needs_user_approval(
|
||||
"REUSE",
|
||||
tc.resolve_toolchain(tc.ToolchainConstraints(existing_language="python")),
|
||||
[],
|
||||
_need(approval_context="covered by SCAFFOLD-TRANCHE-004"),
|
||||
)
|
||||
assert gate["required"] is False
|
||||
assert gate["covered_by"] == "covered by SCAFFOLD-TRANCHE-004"
|
||||
|
||||
|
||||
# ── M. fresh-runtime decision trace recovery ────────────────────────────────
|
||||
|
||||
|
||||
def test_m_fresh_runtime_trace_recovery(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "proj")
|
||||
doc = s.init(purpose="test")
|
||||
trace = cd.make_decision(
|
||||
_need(), authorities={"rune_spine": _spine_doc()}, project_toolchain={"existing_language": "python"}
|
||||
)
|
||||
cd.generate_contract(s, doc, trace, contract_id="LIVE-1", scope=["src/export"])
|
||||
s.transition_contract(doc, "LIVE-1", "approved")
|
||||
s.save(doc)
|
||||
# fresh interpreter, no chat transcript
|
||||
script = (
|
||||
"import sys, json;"
|
||||
"sys.path.insert(0, %r);"
|
||||
"from rune import scaffold;"
|
||||
"s = scaffold.Scaffold(%r, 'proj');"
|
||||
"doc = s.load();"
|
||||
"c = s.contract(doc, 'LIVE-1');"
|
||||
"t = c['ext']['decision_trace'];"
|
||||
"print(json.dumps({'need': t['need']['objective'],"
|
||||
"'resolution': t['resolution'],"
|
||||
"'rationale': t['rationale'][:40],"
|
||||
"'toolchain': t['toolchain']['selected_language'],"
|
||||
"'approval_required': t['approval']['required'],"
|
||||
"'contract_phase': c['phase'],"
|
||||
"'candidates': [x['id'] for x in t['candidates']]}))"
|
||||
) % (os.path.join(os.path.dirname(__file__), "..", "src"), str(tmp_path / ".rune" / "scaffold"))
|
||||
r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
rec = json.loads(r.stdout.strip())
|
||||
assert rec["need"] == "persistent project export"
|
||||
assert rec["resolution"] == "REUSE"
|
||||
assert rec["toolchain"] == "python"
|
||||
assert rec["approval_required"] is False
|
||||
assert rec["contract_phase"] == "approved"
|
||||
assert "capability:exporter" in rec["candidates"]
|
||||
|
||||
|
||||
# ── contract generation populates fields from the trace ─────────────────────
|
||||
|
||||
|
||||
def test_generated_contract_fields_from_trace(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "proj")
|
||||
doc = s.init(purpose="test")
|
||||
trace = cd.make_decision(
|
||||
_need(
|
||||
objective="add encrypted export",
|
||||
constraints={"contracts": ["contract:csv_export", "contract:encrypted_export"]},
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by SCAFFOLD-TRANCHE-004",
|
||||
),
|
||||
authorities={"rune_spine": _spine_doc()},
|
||||
project_toolchain={"existing_language": "python"},
|
||||
)
|
||||
cd.generate_contract(s, doc, trace, contract_id="GEN-1", scope=["src/export"])
|
||||
c = s.contract(doc, "GEN-1")
|
||||
assert c["identities"]["resolution"] == "ADAPT_EXISTING"
|
||||
assert c["identities"]["toolchain"]["selected_language"] == "python"
|
||||
assert "capability:exporter" in c["identities"]["affected_entities"]
|
||||
assert c["identities"]["regression_obligations"] # auto BUILD-BUT-DON'T-BREAK
|
||||
assert c["ext"]["decision_trace"]["need"]["need_id"] == "need-001"
|
||||
|
||||
|
||||
# ── failure behavior (§28) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_stale_candidate_rejected_in_live_path():
|
||||
doc = _spine_doc()
|
||||
e = ss.entity(doc, "capability:exporter")
|
||||
e["status"] = "STALE"
|
||||
trace = cd.make_decision(_need(), authorities={"rune_spine": doc})
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_merge_build_new_never_upgrades_from_insufficient():
|
||||
build = {"resolution": "BUILD_NEW", "rationale": "brittle"}
|
||||
insuff = {"resolution": "INSUFFICIENT_EVIDENCE", "rationale": "weak"}
|
||||
merged = cd.merge_resolutions([build, insuff])
|
||||
assert merged["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
@@ -0,0 +1,551 @@
|
||||
"""test_construction_interface.py — SCAFFOLD-TRANCHE-007 test plans
|
||||
§29 (interface core), §30 (authority), §31 (pattern precision — integration),
|
||||
§17 (provider independence)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
|
||||
def _fixture_authorities():
|
||||
"""A self-contained project-scaffold authority (external-project mode
|
||||
without any real external repo — §16)."""
|
||||
spine = ss.new_doc()
|
||||
ss.add_entity(
|
||||
spine, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(spine, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
return spine
|
||||
|
||||
|
||||
def _iface(tmp_path, builder="test-builder", with_authority=True):
|
||||
store = str(tmp_path / ".rune" / "scaffold")
|
||||
iface = ci.ConstructionInterface(builder=builder, project_store=store, project_id="fixture-proj")
|
||||
if with_authority:
|
||||
# inject the fixture authority INTO the fixture scaffold store so
|
||||
# external-project mode has structural truth to consult
|
||||
iface.scaffold.init(purpose="fixture")
|
||||
doc = iface.scaffold.load()
|
||||
for e in _fixture_authorities()["entities"]:
|
||||
doc["entities"].append(json.loads(json.dumps(e)))
|
||||
for r in _fixture_authorities()["relationships"]:
|
||||
doc["relationships"].append(json.loads(json.dumps(r)))
|
||||
iface.scaffold.save(doc)
|
||||
return iface
|
||||
|
||||
|
||||
def _submit(iface, **kw):
|
||||
payload = dict(
|
||||
objective="persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=[],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by fixture authority",
|
||||
)
|
||||
payload.update(kw)
|
||||
return iface.submit_need(**payload)
|
||||
|
||||
|
||||
# ── §29 interface core ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_29a_submit_need(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
assert env["schema"] == ci.INTERFACE_SCHEMA_VERSION
|
||||
assert env["operation"] == "submit_need"
|
||||
assert env["success"]
|
||||
assert env["ids"]["need_id"]
|
||||
assert env["state"]["resolution"] == "REUSE"
|
||||
assert env["state"]["builder"] == "test-builder"
|
||||
|
||||
|
||||
def test_29b_retrieve_decision(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
cid = env["ids"]["contract_id"]
|
||||
d = iface.inspect_decision(cid)
|
||||
assert d["success"]
|
||||
assert d["state"]["resolution"] == "REUSE"
|
||||
assert d["state"]["toolchain"]["selected_language"] == "python"
|
||||
assert any(f["candidate"] == "capability:exporter" for f in d["state"]["fits"])
|
||||
|
||||
|
||||
def test_29c_retrieve_contract(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
r = iface.resume(cid)
|
||||
assert r["success"]
|
||||
assert r["state"]["phase"] == "draft"
|
||||
assert r["state"]["need"]["objective"] == "persistent project export"
|
||||
|
||||
|
||||
def test_29d_allowed_scope(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
s = iface.get_scope(cid)
|
||||
assert s["success"]
|
||||
assert "allowed_paths" in s["state"]
|
||||
assert "affected_structural_entities" in s["state"]
|
||||
assert "unresolved_surfaces" in s["state"]
|
||||
assert "layout_obligations" in s["state"]
|
||||
|
||||
|
||||
def test_29e_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
assert o["success"]
|
||||
assert o["state"]["obligations"]
|
||||
provs = {x["provenance"] for x in o["state"]["obligations"]}
|
||||
assert provs <= {"STRUCTURAL", "PATTERN", "LAYOUT_CONTRACT"}
|
||||
|
||||
|
||||
def test_29f_report_observation(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
r = iface.report_observation(cid, [{"entity": "capability:exporter", "detail": "exercised by new test"}])
|
||||
assert r["success"]
|
||||
assert r["state"]["recorded"] == 1
|
||||
assert "cannot self-certify" not in r["state"]["note"]
|
||||
assert "determined by Scaffolding gates" in r["state"]["note"]
|
||||
|
||||
|
||||
def test_29g_verification_state(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
v = iface.verify(cid, changed_files=["src/new_thing.py"])
|
||||
assert v["success"] or v["errors"]
|
||||
assert "scope_check" in v["state"] or v["errors"]
|
||||
|
||||
|
||||
def test_29h_resume_fresh_process(tmp_path):
|
||||
"""§11 THE MAJOR ACCEPTANCE CONDITION: a fresh builder process resumes
|
||||
from contract id alone."""
|
||||
iface = _iface(tmp_path, builder="first-builder")
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/export_module.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by fixture authority",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
iface.transition(cid, "approved")
|
||||
script = f"""
|
||||
import sys, json
|
||||
sys.path.insert(0, {os.path.join(ROOT, "src")!r})
|
||||
from rune import construction_interface as ci
|
||||
iface = ci.ConstructionInterface(builder="fresh-builder",
|
||||
project_store={str(tmp_path / ".rune" / "scaffold")!r},
|
||||
project_id="fixture-proj")
|
||||
env = iface.resume({cid!r})
|
||||
print(json.dumps(env))
|
||||
"""
|
||||
r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
env = json.loads(r.stdout)
|
||||
assert env["success"]
|
||||
st = env["state"]
|
||||
assert st["need"]["objective"] == "persistent project export"
|
||||
assert st["decision"]["resolution"] == "REUSE"
|
||||
assert st["phase"] == "approved"
|
||||
assert st["scope"]
|
||||
assert st["obligations"]
|
||||
assert st["next_action"]
|
||||
assert st["builder"] == "fresh-builder" # this session's identity
|
||||
|
||||
|
||||
def test_29i_json_roundtrip(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
blob = json.dumps(env)
|
||||
back = json.loads(blob)
|
||||
assert back["operation"] == env["operation"]
|
||||
assert back["state"]["resolution"] == env["state"]["resolution"]
|
||||
|
||||
|
||||
def test_29j_unknown_contract(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
for op in (
|
||||
lambda: iface.inspect_decision("nope"),
|
||||
lambda: iface.get_scope("nope"),
|
||||
lambda: iface.get_obligations("nope"),
|
||||
lambda: iface.resume("nope"),
|
||||
):
|
||||
env = op()
|
||||
assert not env["success"]
|
||||
assert env["errors"][0]["error"] == ci.Errors.UNKNOWN_CONTRACT
|
||||
|
||||
|
||||
def test_29k_approval_required(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.submit_need(
|
||||
"build a whole new application from scratch",
|
||||
constraints={},
|
||||
affected_surface=[],
|
||||
evidence_refs=["x"],
|
||||
approval_context="",
|
||||
) # no approval context
|
||||
# new-capability construction without approval context → gate fires
|
||||
if env["state"]["approval"]["required"]:
|
||||
assert env["errors"]
|
||||
assert any(e["error"] == ci.Errors.APPROVAL_REQUIRED for e in env["errors"])
|
||||
else:
|
||||
# covered path must say so
|
||||
assert env["state"]["approval"]["covered_by"] or not env["state"]["approval"]["reasons"]
|
||||
|
||||
|
||||
def test_29l_scope_violation_refused(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/export_module.py"], # scoped surface
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by fixture authority",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
iface.transition(cid, "approved")
|
||||
iface.transition(cid, "building")
|
||||
iface.transition(cid, "verifying")
|
||||
v = iface.verify(cid, changed_files=["src/completely/unrelated.py"])
|
||||
# out-of-scope change must surface as a violation, never green
|
||||
assert v["state"]["scope_check"]["verdict"] == "red" or any(
|
||||
e["error"] == ci.Errors.SCOPE_VIOLATION for e in v["errors"]
|
||||
)
|
||||
|
||||
|
||||
def test_29m_failed_verification_cannot_close(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
# contract is in draft — closure must be refused by the phase map
|
||||
env = iface.close(cid, observed_evidence=[{"entity": "capability:exporter", "detail": "claimed done"}])
|
||||
assert not env["success"]
|
||||
assert env["errors"][0]["error"] == ci.Errors.VERIFICATION_FAILED
|
||||
|
||||
|
||||
def test_29n_stale_state_store_write(tmp_path):
|
||||
"""STORE_WRITE_FAILED: an unwritable store surfaces as an explicit
|
||||
error, not a silent lie."""
|
||||
iface = _iface(tmp_path)
|
||||
# break the store path
|
||||
iface.scaffold.current = "/proc/nonexistent/current.json"
|
||||
env = iface.report_observation("any", [{"entity": "x"}])
|
||||
assert not env["success"] or env["errors"]
|
||||
|
||||
|
||||
# ── §30 authority ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_30a_rune_mode_reads_rune_authority():
|
||||
"""Rune-internal mode consults the real Spine store (read-only)."""
|
||||
iface = ci.ConstructionInterface(builder="t")
|
||||
auth = iface._authorities()
|
||||
assert "rune_spine" in auth
|
||||
assert iface.mode == "rune_internal"
|
||||
|
||||
|
||||
def test_30b_external_mode_reads_project_scaffold(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
auth = iface._authorities()
|
||||
assert "rune_spine" not in auth # external project has no Rune Spine
|
||||
assert iface.mode == "external_project"
|
||||
assert auth["project_scaffold"]["entities"]
|
||||
|
||||
|
||||
def test_30c_builder_cannot_rewrite_structural_authority():
|
||||
"""The interface exposes NO operation that writes structural truth.
|
||||
(The only writes are contract/observation/session records in the
|
||||
scaffold store — never Spine entities.)"""
|
||||
public = [m for m in dir(ci.ConstructionInterface) if not m.startswith("_")]
|
||||
for forbidden in ("rewrite_spine", "set_entity", "promote", "supersede", "build_spine", "add_relationship"):
|
||||
assert forbidden not in public
|
||||
|
||||
|
||||
def test_30d_builder_cannot_override_user_locked(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
from rune import layout_contract as lc
|
||||
|
||||
doc = iface.scaffold.load()
|
||||
lc.define_region(doc, "footer-bar", provenance="scaffold")
|
||||
lc.bind_component(doc, "version_display", "footer-bar", lock=True, provenance="user", order=0)
|
||||
iface.scaffold.save(doc)
|
||||
# no interface operation amends layout at all
|
||||
public = [m for m in dir(ci.ConstructionInterface) if not m.startswith("_")]
|
||||
assert not any("layout" in m and "amend" in m for m in public)
|
||||
# and the direct amend path still refuses non-user authority
|
||||
doc2 = iface.scaffold.load()
|
||||
with pytest.raises(ValueError):
|
||||
lc.amend_layout_contract(
|
||||
doc2, "version_display", new_region="header", authorized_by="builder", reason="prefer it"
|
||||
)
|
||||
|
||||
|
||||
def test_30e_builder_cannot_self_certify(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
cid = _submit(iface)["ids"]["contract_id"]
|
||||
# no interface operation takes "done=True"
|
||||
v = iface.verify(cid)
|
||||
assert "cannot self-certify" in v["state"]["remaining"]
|
||||
# closure from draft fails
|
||||
env = iface.close(cid, observed_evidence=[{"entity": "x"}])
|
||||
assert not env["success"]
|
||||
|
||||
|
||||
def test_30f_builder_identity_neutral(tmp_path):
|
||||
"""§17: two builders with identical input get identical construction
|
||||
behavior (identity recorded, semantics unchanged)."""
|
||||
results = []
|
||||
for b in ("mimo", "kimi", "deterministic-script"):
|
||||
iface = _iface(tmp_path / b, builder=b)
|
||||
env = _submit(iface)
|
||||
results.append(
|
||||
(
|
||||
env["state"]["resolution"],
|
||||
env["state"]["completeness"],
|
||||
env["state"]["toolchain"] if "toolchain" in env["state"] else None,
|
||||
[o["id"] for o in iface.get_obligations(env["ids"]["contract_id"])]["obligations"] if False else None,
|
||||
)
|
||||
)
|
||||
resolutions = {r[0] for r in results}
|
||||
completions = {r[1] for r in results}
|
||||
assert len(resolutions) == 1 and len(completions) == 1
|
||||
|
||||
|
||||
# ── §31 pattern precision (integration through the interface) ───────────────
|
||||
|
||||
|
||||
def test_31a_ui_task_derives_ui_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
from rune import layout_contract as lc
|
||||
|
||||
doc = iface.scaffold.load()
|
||||
lc.define_region(doc, "footer-bar", provenance="scaffold")
|
||||
lc.bind_component(doc, "version_display", "footer-bar", provenance="scaffold")
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.submit_need(
|
||||
"adjust the tui version widget",
|
||||
constraints={},
|
||||
affected_surface=["ui:version_display"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
ids = {x["id"] for x in o["state"]["obligations"]}
|
||||
assert any("ui-" in i or "layout:" in i for i in ids)
|
||||
|
||||
|
||||
def test_31b_non_ui_task_no_active_ui_obligations(tmp_path):
|
||||
"""§0 regression through the interface: non-UI work gets NO active
|
||||
ui-* obligations."""
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
ui_active = [x for x in o["state"]["obligations"] if x["id"].startswith("ui-")]
|
||||
assert ui_active == []
|
||||
|
||||
|
||||
def test_31c_release_task_derives_release_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
# the fixture authority needs the deploy structural truth for fit
|
||||
doc = iface.scaffold.load()
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="module:deploy_stage",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_stage.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(doc, id="contract:monotonic_promotion", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="module:deploy_stage", to_id="contract:monotonic_promotion", evidence_refs=["f"]
|
||||
)
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.submit_need(
|
||||
"repair the deploy promotion ordering",
|
||||
constraints={"contracts": ["contract:monotonic_promotion"]},
|
||||
affected_surface=["scripts/deploy_stage.py", "scripts/deploy_promotion.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
ids = {x["id"] for x in o["state"]["obligations"]}
|
||||
assert "rel-monotonic-promotion" in ids or any("rel-" in i for i in ids)
|
||||
|
||||
|
||||
def test_31d_non_release_task_no_promotion_obligations(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = _submit(iface)
|
||||
cid = env["ids"]["contract_id"]
|
||||
o = iface.get_obligations(cid)
|
||||
rel_active = [x for x in o["state"]["obligations"] if x["id"].startswith("rel-")]
|
||||
assert rel_active == []
|
||||
|
||||
|
||||
def test_31g_partial_pattern_inspectable_without_activation(tmp_path):
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.submit_need(
|
||||
"adjust the tui backlog plumbing",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/rune/tui_backlog.py", "src/export_module.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
d = iface.inspect_decision(cid)
|
||||
considered = {a["pattern"]: a["verdict"] for a in d["state"]["patterns_considered"]}
|
||||
# PARTIAL patterns remain VISIBLE as candidates (inspectable)
|
||||
assert considered
|
||||
# but their UI obligations did not enter the ACTIVE obligation set
|
||||
o = iface.get_obligations(cid)
|
||||
ui_active = [x for x in o["state"]["obligations"] if x["id"].startswith("ui-")]
|
||||
assert ui_active == []
|
||||
|
||||
|
||||
# ── CLI transport ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cli_roundtrip(tmp_path):
|
||||
store = tmp_path / ".rune" / "scaffold"
|
||||
iface = ci.ConstructionInterface(builder="seed", project_store=str(store), project_id="cli-fixture")
|
||||
iface.scaffold.init(purpose="cli")
|
||||
doc = iface.scaffold.load()
|
||||
for e in _fixture_authorities()["entities"]:
|
||||
doc["entities"].append(json.loads(json.dumps(e)))
|
||||
for r in _fixture_authorities()["relationships"]:
|
||||
doc["relationships"].append(json.loads(json.dumps(r)))
|
||||
iface.scaffold.save(doc)
|
||||
env = {
|
||||
"objective": "persistent project export",
|
||||
"constraints": {"contracts": ["contract:csv_export"]},
|
||||
"affected_surface": [],
|
||||
"evidence_refs": ["x"],
|
||||
"approval_context": "covered",
|
||||
}
|
||||
r = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rune.construction_interface",
|
||||
"submit-need",
|
||||
json.dumps(env),
|
||||
"--builder",
|
||||
"cli-builder",
|
||||
"--project-store",
|
||||
str(store),
|
||||
"--project-id",
|
||||
"cli-fixture",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "PYTHONPATH": os.path.join(ROOT, "src")},
|
||||
)
|
||||
assert r.returncode == 0, r.stderr[-400:]
|
||||
out = json.loads(r.stdout[r.stdout.index("{") :])
|
||||
assert out["success"]
|
||||
assert out["state"]["resolution"] == "REUSE"
|
||||
# resume through the CLI from the returned contract id
|
||||
r2 = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rune.construction_interface",
|
||||
"resume",
|
||||
out["ids"]["contract_id"],
|
||||
"--builder",
|
||||
"cli-2",
|
||||
"--project-store",
|
||||
str(store),
|
||||
"--project-id",
|
||||
"cli-fixture",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "PYTHONPATH": os.path.join(ROOT, "src")},
|
||||
)
|
||||
assert r2.returncode == 0, r2.stderr[-400:]
|
||||
out2 = json.loads(r2.stdout[r2.stdout.index("{") :])
|
||||
assert out2["state"]["need"]["objective"] == "persistent project export"
|
||||
|
||||
|
||||
# ── §28 capabilities / schema negotiation (SCAFFOLD-TRANCHE-008) ────────────
|
||||
|
||||
|
||||
def test_capabilities_operation(tmp_path):
|
||||
"""Builders can detect the supported interface schema + operations
|
||||
BEFORE calling anything (T7 §28 requirement, closed by this tranche)."""
|
||||
iface = _iface(tmp_path)
|
||||
env = iface.capabilities()
|
||||
assert env["success"]
|
||||
st = env["state"]
|
||||
assert st["schema_version"] == ci.INTERFACE_SCHEMA_VERSION
|
||||
for op in (
|
||||
"submit_need",
|
||||
"inspect_decision",
|
||||
"get_scope",
|
||||
"get_obligations",
|
||||
"report_observation",
|
||||
"verify",
|
||||
"transition",
|
||||
"close",
|
||||
"resume",
|
||||
"capabilities",
|
||||
):
|
||||
assert op in st["operations"]
|
||||
assert ci.Errors.SCOPE_VIOLATION in st["error_states"]
|
||||
assert ci.Errors.UNKNOWN_CONTRACT in st["error_states"]
|
||||
# mode + project identity exposed for builder routing
|
||||
assert st["mode"] == "external_project"
|
||||
assert st["project"] == "fixture-proj"
|
||||
|
||||
|
||||
def test_capabilities_cli_roundtrip(tmp_path):
|
||||
"""The CLI transport exposes the same capability set (§3 transport
|
||||
parity: the builder must not need the Python API for schema detection)."""
|
||||
import subprocess
|
||||
|
||||
env = {"objective": "x", "constraints": {}, "affected_surface": [], "evidence_refs": [], "approval_context": ""}
|
||||
r = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rune.construction_interface",
|
||||
"capabilities",
|
||||
"--builder",
|
||||
"probe",
|
||||
"--project-store",
|
||||
str(tmp_path / ".rune" / "scaffold"),
|
||||
"--project-id",
|
||||
"fixture-proj",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "PYTHONPATH": os.path.join(ROOT, "src")},
|
||||
)
|
||||
assert r.returncode == 0, r.stderr[-300:]
|
||||
out = json.loads(r.stdout[r.stdout.index("{") :])
|
||||
assert out["success"]
|
||||
assert out["state"]["schema_version"] == ci.INTERFACE_SCHEMA_VERSION
|
||||
assert "capabilities" in out["state"]["operations"]
|
||||
@@ -0,0 +1,265 @@
|
||||
"""test_construction_mode.py — SCAFFOLD-TRANCHE-0010: §19 classification
|
||||
matrix, §20 context-sensitivity, §3 BUILD_NEW != PROJECT_BUILD, §23
|
||||
override provenance, shared-context projection."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_mode as cm # noqa: E402
|
||||
|
||||
|
||||
# ── §19 classification matrix ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_19a_typo_fix_is_local_change():
|
||||
d = cm.classify_mode("fix this typo in the existing module", project_exists=True)
|
||||
assert d.mode in ("LOCAL_CHANGE", "EXISTING_PROJECT_CHANGE")
|
||||
assert d.mode != "PROJECT_BUILD"
|
||||
|
||||
|
||||
def test_19b_add_csv_export_existing_app():
|
||||
d = cm.classify_mode("add CSV export to this existing app", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
def test_19c_schema_replacement_migration():
|
||||
d = cm.classify_mode("replace the storage schema in this existing project", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
def test_19d_breaking_api_change_stays_existing_project():
|
||||
d = cm.classify_mode("make a breaking change to the existing public api", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
def test_19e_standalone_scheduler_is_project_build():
|
||||
d = cm.classify_mode("build me a new standalone practice scheduler app", project_exists=False)
|
||||
assert d.mode == "PROJECT_BUILD"
|
||||
assert d.bootstrap_required is True
|
||||
|
||||
|
||||
def test_19f_new_web_service_is_project_build():
|
||||
d = cm.classify_mode("create a new web service for session notes", project_exists=False)
|
||||
assert d.mode == "PROJECT_BUILD"
|
||||
|
||||
|
||||
def test_19g_build_confidence_not_construction():
|
||||
d = cm.classify_mode("build my confidence with guitar improvisation")
|
||||
assert d.mode == "NOT_CONSTRUCTION"
|
||||
|
||||
|
||||
def test_19h_make_a_list_not_project_build():
|
||||
d = cm.classify_mode("make me a list of chord substitutions")
|
||||
assert d.mode == "NOT_CONSTRUCTION"
|
||||
|
||||
|
||||
def test_19i_ambiguous_is_unknown_not_project_build():
|
||||
d = cm.classify_mode("build something to help with this")
|
||||
assert d.mode == "UNKNOWN"
|
||||
assert d.clarification_required is True
|
||||
assert d.probe_questions
|
||||
|
||||
|
||||
def test_19j_capability_build_new_inside_rune_is_existing_project_change():
|
||||
"""§3 THE KEY DISTINCTION: capability resolution may be BUILD_NEW while
|
||||
construction mode remains EXISTING_PROJECT_CHANGE — new capability
|
||||
INSIDE the existing Rune project."""
|
||||
d = cm.classify_mode("implement a new parser module for Rune", target_project="rune", project_exists=True)
|
||||
assert d.mode == "EXISTING_PROJECT_CHANGE"
|
||||
assert d.mode != "PROJECT_BUILD"
|
||||
|
||||
|
||||
# ── §20 context-sensitivity: same request, different structure ──────────────
|
||||
|
||||
|
||||
def test_20_same_request_resolves_by_context():
|
||||
obj = "add appointment booking"
|
||||
a = cm.classify_mode(obj, target_project="scheduler-app", project_exists=True)
|
||||
b = cm.classify_mode(obj, target_project=None, project_exists=False)
|
||||
assert a.mode == "EXISTING_PROJECT_CHANGE"
|
||||
assert b.mode in ("UNKNOWN", "PROJECT_BUILD")
|
||||
assert a.mode != b.mode
|
||||
|
||||
|
||||
def test_20b_existing_project_contradicts_standalone_language():
|
||||
"""§5: project evidence wins over standalone wording — contradictory
|
||||
evidence → UNKNOWN with clarification, never silent PROJECT_BUILD."""
|
||||
d = cm.classify_mode("build a standalone export tool", target_project="rune", project_exists=True)
|
||||
assert d.mode == "UNKNOWN"
|
||||
assert d.clarification_required is True
|
||||
|
||||
|
||||
# ── UNKNOWN discipline ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unknown_never_silently_escalates():
|
||||
for obj in ("build something to help with this", "create a thing for the workflow", "implement improvements"):
|
||||
d = cm.classify_mode(obj)
|
||||
assert d.mode in ("UNKNOWN",)
|
||||
assert d.clarification_required
|
||||
|
||||
|
||||
def test_empty_objective_unknown():
|
||||
d = cm.classify_mode("")
|
||||
assert d.mode == "UNKNOWN"
|
||||
assert d.probe_questions
|
||||
|
||||
|
||||
# ── §23 manual override ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_23_override_from_unknown():
|
||||
d = cm.classify_mode("build something to help with this")
|
||||
assert d.mode == "UNKNOWN"
|
||||
forced = cm.classify_mode(
|
||||
"build something to help with this", explicit_override="PROJECT_BUILD", override_authority="user"
|
||||
)
|
||||
assert forced.mode == "PROJECT_BUILD"
|
||||
assert forced.override == {"authority": "user", "from_mode": None}
|
||||
assert forced.bootstrap_required is True
|
||||
|
||||
|
||||
def test_23_override_to_existing_project_validated():
|
||||
d = cm.classify_mode("build a standalone export tool", project_exists=True)
|
||||
assert d.mode == "UNKNOWN"
|
||||
forced = cm.classify_mode(
|
||||
"build a standalone export tool",
|
||||
project_exists=True,
|
||||
target_project="rune",
|
||||
explicit_override="EXISTING_PROJECT_CHANGE",
|
||||
override_authority="operator",
|
||||
)
|
||||
assert forced.mode == "EXISTING_PROJECT_CHANGE"
|
||||
assert forced.override["authority"] == "operator"
|
||||
|
||||
|
||||
def test_23_override_invalid_mode_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
cm.classify_mode("anything", explicit_override="MAGIC_MODE")
|
||||
|
||||
|
||||
# ── model proposal boundary ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_model_proposal_recorded_never_authority():
|
||||
prop = {"pattern_id": "x", "provider": "p", "confidence": 0.4}
|
||||
d = cm.classify_mode("fix this typo in the existing module", project_exists=True, model_proposal=prop)
|
||||
assert d.mode == "LOCAL_CHANGE" # deterministic evidence decided
|
||||
assert d.proposed_by_model == prop # recorded, not governing
|
||||
|
||||
|
||||
# ── to_dict roundtrip ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mode_decision_serializable():
|
||||
d = cm.classify_mode("add CSV export to this existing app", target_project="garden", project_exists=True)
|
||||
blob = d.to_dict()
|
||||
assert blob["mode"] == "EXISTING_PROJECT_CHANGE"
|
||||
assert blob["target_project"] == "garden"
|
||||
assert isinstance(blob["evidence"], list)
|
||||
|
||||
|
||||
# ── Tranche 10: interface consumers + shared context ────────────────────────
|
||||
|
||||
|
||||
def test_interface_classify_equivalence(tmp_path):
|
||||
"""§24: ONE classifier — the Construction Interface and the direct
|
||||
call produce identical semantics for identical input."""
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="mimo", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx"
|
||||
)
|
||||
iface.scaffold.init(purpose="t")
|
||||
via_interface = iface.classify("build me a new standalone practice scheduler app", project_exists=False)
|
||||
direct = cm.classify_mode("build me a new standalone practice scheduler app", project_exists=False)
|
||||
assert via_interface["state"]["mode"] == direct.mode == "PROJECT_BUILD"
|
||||
# builder identity recorded, semantics unchanged
|
||||
assert via_interface["state"]["builder"] == "mimo"
|
||||
|
||||
|
||||
def test_interface_classify_unknown_carries_probes(tmp_path):
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="mimo", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx"
|
||||
)
|
||||
iface.scaffold.init(purpose="t")
|
||||
env = iface.classify("build something to help with this")
|
||||
assert env["state"]["mode"] == "UNKNOWN"
|
||||
assert env["state"]["probe_questions"]
|
||||
assert not env["success"] # UNKNOWN is a non-success that asks
|
||||
|
||||
|
||||
def test_construction_context_projection(tmp_path):
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(builder="b", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx")
|
||||
iface.scaffold.init(purpose="t")
|
||||
# seed the structural truth the Need's fit requires
|
||||
from rune import structural_store as ss
|
||||
|
||||
doc0 = iface.scaffold.load()
|
||||
ss.add_entity(
|
||||
doc0, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc0, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc0, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
iface.scaffold.save(doc0)
|
||||
# no active construction
|
||||
empty = iface.construction_context()
|
||||
assert empty["state"]["active"] is False
|
||||
# with an active contract
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=["src/export.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
ctx = iface.construction_context(cid)
|
||||
st = ctx["state"]
|
||||
assert st["active"] is True
|
||||
assert st["need"] == "persistent project export"
|
||||
assert st["phase"] == "draft"
|
||||
assert st["scope"] == ["src/export.py"]
|
||||
assert st["next_action"] == "review for approval"
|
||||
|
||||
|
||||
def test_construction_mode_in_decision_trace(tmp_path):
|
||||
"""make_decision carries the contextual mode — ONE classifier behind
|
||||
both the interface and the decision path."""
|
||||
from rune import construction_interface as ci
|
||||
|
||||
iface = ci.ConstructionInterface(builder="b", project_store=str(tmp_path / ".rune" / "scaffold"), project_id="fx")
|
||||
iface.scaffold.init(purpose="t")
|
||||
doc = iface.scaffold.load()
|
||||
from rune import structural_store as ss
|
||||
|
||||
ss.add_entity(
|
||||
doc, id="capability:exporter", kind="capability", identities={}, maturity="WIRED", evidence_refs=["f"]
|
||||
)
|
||||
ss.add_entity(doc, id="contract:csv_export", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
doc, type="implements", from_id="capability:exporter", to_id="contract:csv_export", evidence_refs=["f"]
|
||||
)
|
||||
iface.scaffold.save(doc)
|
||||
env = iface.submit_need(
|
||||
"persistent project export",
|
||||
constraints={"contracts": ["contract:csv_export"]},
|
||||
affected_surface=[],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
cid = env["ids"]["contract_id"]
|
||||
doc2 = iface.scaffold.load()
|
||||
c = iface.scaffold.contract(doc2, cid)
|
||||
mode = c["ext"]["decision_trace"]["construction_mode"]
|
||||
assert mode["mode"] == "EXISTING_PROJECT_CHANGE" # rune-internal project
|
||||
@@ -0,0 +1,471 @@
|
||||
"""test_construction_pattern.py — SCAFFOLD-TRANCHE-006 test plans §28-§32."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import construction_pattern as cp # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune.construction_patterns_builtin import ( # noqa: E402
|
||||
BUILTIN_PATTERNS,
|
||||
RELEASE_DEPLOYMENT,
|
||||
get_pattern,
|
||||
)
|
||||
|
||||
import construction_guards as cg # noqa: E402
|
||||
|
||||
|
||||
def _spine():
|
||||
spine = ss.new_doc()
|
||||
ss.add_entity(
|
||||
spine,
|
||||
id="module:deploy_stage",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_stage.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(spine, id="contract:monotonic_promotion", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine,
|
||||
type="implements",
|
||||
from_id="module:deploy_stage",
|
||||
to_id="contract:monotonic_promotion",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(
|
||||
spine,
|
||||
id="module:deploy_qualify",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_qualify.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(spine, id="contract:fail_closed_deployment", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine,
|
||||
type="implements",
|
||||
from_id="module:deploy_qualify",
|
||||
to_id="contract:fail_closed_deployment",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
return spine
|
||||
|
||||
|
||||
def _need(**kw):
|
||||
d = dict(
|
||||
need_id="n6",
|
||||
objective="repair the deploy promotion ordering",
|
||||
origin="user",
|
||||
constraints={"contracts": ["contract:monotonic_promotion"]},
|
||||
affected_surface=["scripts/deploy_stage.py", "scripts/deploy_promotion.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
d.update(kw)
|
||||
return cd.Need(**d)
|
||||
|
||||
|
||||
def _surface(need):
|
||||
authorities = {"rune_spine": _spine()}
|
||||
return cd.resolve_affected_surface(need, authorities), authorities
|
||||
|
||||
|
||||
# ── §28 pattern object ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_28a_roundtrip():
|
||||
d = RELEASE_DEPLOYMENT.to_dict()
|
||||
p2 = cp.ConstructionPattern.from_dict(d)
|
||||
assert p2.to_dict() == d
|
||||
|
||||
|
||||
def test_28b_open_world_ext():
|
||||
p = cp.ConstructionPattern("p", "purpose", wibble="future-data")
|
||||
assert p.to_dict()["wibble"] == "future-data"
|
||||
assert cp.ConstructionPattern.from_dict(p.to_dict()).ext["wibble"] == "future-data"
|
||||
|
||||
|
||||
def test_28c_revision_preserved_and_advances():
|
||||
p2 = RELEASE_DEPLOYMENT.revise(
|
||||
reason="new incident evidence", evidence=["new-incident"], changes={"maturity": "EXERCISED"}
|
||||
)
|
||||
assert p2.revision == RELEASE_DEPLOYMENT.revision + 1
|
||||
hist = p2.ext["revision_history"]
|
||||
assert hist[-1]["prior_revision"] == RELEASE_DEPLOYMENT.revision
|
||||
assert hist[-1]["evidence"] == ["new-incident"]
|
||||
# revision history survives a roundtrip (§17: old-rule traceability)
|
||||
assert cp.ConstructionPattern.from_dict(p2.to_dict()).ext["revision_history"] == hist
|
||||
# original untouched (no invisible mutation)
|
||||
assert RELEASE_DEPLOYMENT.maturity == "UNIT-PROVEN"
|
||||
|
||||
|
||||
def test_28d_provenance_preserved():
|
||||
d = RELEASE_DEPLOYMENT.to_dict()
|
||||
# provenance entries are enriched "ref (why)" strings — check by ref
|
||||
assert any(e.startswith("maintenance:deploy-order-race") for e in d["provenance"])
|
||||
assert any(e.startswith("DEPLOY-MONOTONIC-001") for e in d["provenance"])
|
||||
assert cp.ConstructionPattern.from_dict(d).provenance == d["provenance"]
|
||||
|
||||
|
||||
def test_28e_maturity_preserved():
|
||||
for p in BUILTIN_PATTERNS:
|
||||
assert p.maturity in ("CANDIDATE", "UNIT-PROVEN", "EXERCISED", "REPEATEDLY-PROVEN", "DEGRADED")
|
||||
# initial patterns stay appropriately immature (§15)
|
||||
assert all(p.maturity in ("CANDIDATE", "UNIT-PROVEN") for p in BUILTIN_PATTERNS)
|
||||
|
||||
|
||||
def test_28f_obligation_validation():
|
||||
with pytest.raises(ValueError):
|
||||
cp.ConstructionPattern("p", "purpose", obligations=[{"id": "x"}]) # missing text/when
|
||||
|
||||
|
||||
def test_28g_registry_revision_guard():
|
||||
assert get_pattern("release-deployment-change") is not None
|
||||
with pytest.raises(ValueError):
|
||||
get_pattern("release-deployment-change", revision=99)
|
||||
|
||||
|
||||
# ── §29 applicability ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_29a_matching_evidence_applies():
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert app["verdict"] == cp.APPLIES
|
||||
assert app["evidence"] # evidence-backed, not name-based
|
||||
|
||||
|
||||
def test_29b_partial_evidence_partial():
|
||||
need = _need(objective="release notes wording", affected_surface=["docs/RELEASE.md"])
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert app["verdict"] in (cp.PARTIAL, cp.DOES_NOT_APPLY)
|
||||
|
||||
|
||||
def test_29c_contradictory_project_evidence_does_not_apply():
|
||||
"""§3/§29C: project evidence overrides the pattern assumption."""
|
||||
need = _need(
|
||||
constraints={"contracts": [], "pattern_exclusions": ["no deployment system in this project"]},
|
||||
objective="pure algorithm work, no deployment system here",
|
||||
)
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert app["verdict"] == cp.DOES_NOT_APPLY
|
||||
assert "declared exclusion" in app["reason"]
|
||||
|
||||
|
||||
def test_29d_insufficient_evidence_unknown():
|
||||
p = cp.ConstructionPattern("empty", "no signals declared")
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(p, need, surface, auth)
|
||||
assert app["verdict"] == cp.UNKNOWN
|
||||
|
||||
|
||||
def test_29e_name_similarity_alone_never_applies():
|
||||
"""A pattern whose NAME matches but whose signals find no evidence stays
|
||||
UNKNOWN/DOES_NOT_APPLY — names establish nothing (§4)."""
|
||||
p = cp.ConstructionPattern(
|
||||
"deployment-thing",
|
||||
"sounds relevant",
|
||||
signals={"path_contains": ["/nonexistent/xyz/"], "objective_any": ["zzznever"]},
|
||||
)
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(p, need, surface, auth)
|
||||
assert app["verdict"] in (cp.DOES_NOT_APPLY, cp.UNKNOWN)
|
||||
|
||||
|
||||
def test_29f_model_proposal_pending():
|
||||
prop = cp.record_pattern_proposal("some-pattern", provider="local", model="m", evidence=["hunch"], confidence=0.5)
|
||||
assert prop["verification_state"] == "PENDING"
|
||||
with pytest.raises(ValueError):
|
||||
cp.record_pattern_proposal("x", provider="", model="m", evidence=[], confidence=0.5)
|
||||
|
||||
|
||||
def test_29g_project_evidence_overrides_pattern():
|
||||
"""The strongest form of §3: pattern signals say APPLIES, but the project
|
||||
records an explicit exception for the derived obligation — the obligation
|
||||
is suppressed WITH provenance, not deleted (§18)."""
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(
|
||||
RELEASE_DEPLOYMENT,
|
||||
need,
|
||||
surface,
|
||||
auth,
|
||||
project_exceptions=[
|
||||
{
|
||||
"obligation": "rel-monotonic-promotion",
|
||||
"reason": "single-runner serialized deploys documented in project policy",
|
||||
"evidence": "project policy doc",
|
||||
"authority": "user",
|
||||
}
|
||||
],
|
||||
)
|
||||
suppressed = [e for e in deriv["rejected"] if e["id"] == "rel-monotonic-promotion"]
|
||||
assert suppressed and suppressed[0]["verdict"] == "EXCEPTED"
|
||||
assert suppressed[0]["exception_evidence"] == "project policy doc"
|
||||
# the pattern's other obligations still derive
|
||||
assert any(e["id"] == "rel-fail-closed-qualification" for e in deriv["accepted"])
|
||||
|
||||
|
||||
# ── §30 obligation derivation ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_30a_applicable_pattern_derives():
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
assert deriv["accepted"]
|
||||
e = deriv["accepted"][0]
|
||||
assert e["provenance"] == "PATTERN"
|
||||
assert e["pattern_provenance"] # evidence chain carried
|
||||
|
||||
|
||||
def test_30b_non_applicable_pattern_derives_nothing():
|
||||
need = _need(objective="write a poem", affected_surface=["docs/poem.md"])
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
# no obligations accepted as REQUIRED (pattern-level always-obligations
|
||||
# only fire when the PATTERN applies — a DOES_NOT_APPLY pattern's
|
||||
# derivation is not consulted at all by make_decision)
|
||||
assert deriv["accepted"] == [] or all(e["verdict"] == "REQUIRED" for e in deriv["accepted"])
|
||||
|
||||
|
||||
def test_30d_unknown_obligation_not_silently_required():
|
||||
p = cp.ConstructionPattern(
|
||||
"weird",
|
||||
"purpose",
|
||||
obligations=[{"id": "mystery", "obligation": "do the unknown thing", "when": {"unsupported_shape": True}}],
|
||||
)
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(p, need, surface, auth)
|
||||
assert deriv["unresolved"] and deriv["unresolved"][0]["verdict"] == cp.OBL_UNKNOWN
|
||||
assert deriv["accepted"] == []
|
||||
|
||||
|
||||
def test_30e_provenance_distinct():
|
||||
"""§8: structural derivations and pattern derivations carry different
|
||||
provenance kinds in the trace."""
|
||||
need = _need()
|
||||
trace = cd.make_decision(
|
||||
need, authorities={"rune_spine": _spine()}, project_toolchain={"existing_language": "python"}
|
||||
)
|
||||
obl = trace["obligations"]
|
||||
assert "pattern" in obl and obl["pattern"]
|
||||
assert all(o["provenance"] == "PATTERN" for o in obl["pattern"])
|
||||
# structural obligations (regression etc.) remain their own kind
|
||||
assert "regression" in obl
|
||||
|
||||
|
||||
def test_30f_duplicate_obligations_collapse():
|
||||
p1 = cp.ConstructionPattern(
|
||||
"a", "pa", obligations=[{"id": "shared", "obligation": "regression proof", "when": {"always": True}}]
|
||||
)
|
||||
p2 = cp.ConstructionPattern(
|
||||
"b", "pb", obligations=[{"id": "shared", "obligation": "regression proof", "when": {"always": True}}]
|
||||
)
|
||||
d1 = cp.derive_pattern_obligations(p1, _need(), *(_surface(_need())[::-1]))
|
||||
d2 = cp.derive_pattern_obligations(p2, _need(), *(_surface(_need())[::-1]))
|
||||
combined = cp.combine_pattern_obligations([d1, d2])
|
||||
shared = [o for o in combined["obligations"] if o["id"] == "shared"]
|
||||
assert len(shared) == 1
|
||||
# first pattern's provenance kept, later duplicates recorded in also_from
|
||||
assert shared[0]["pattern"] == "a"
|
||||
assert "b" in shared[0]["also_from"]
|
||||
|
||||
|
||||
def test_30g_conflicting_obligations_surface():
|
||||
p1 = cp.ConstructionPattern(
|
||||
"x", "px", obligations=[{"id": "shared", "obligation": "version A of the rule", "when": {"always": True}}]
|
||||
)
|
||||
p2 = cp.ConstructionPattern(
|
||||
"y", "py", obligations=[{"id": "shared", "obligation": "version B of the rule", "when": {"always": True}}]
|
||||
)
|
||||
d1 = cp.derive_pattern_obligations(p1, _need(), *(_surface(_need())[::-1]))
|
||||
d2 = cp.derive_pattern_obligations(p2, _need(), *(_surface(_need())[::-1]))
|
||||
combined = cp.combine_pattern_obligations([d1, d2])
|
||||
assert combined["conflicts"]
|
||||
assert combined["conflicts"][0]["conflict"] == "same id, different text"
|
||||
|
||||
|
||||
# ── §31 pattern-specific ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_31_release_pattern_negative_and_forward():
|
||||
need = _need()
|
||||
surface, auth = _surface(need)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, auth)
|
||||
ids = {e["id"] for e in deriv["accepted"]}
|
||||
assert "rel-negative-candidate-proof" in ids
|
||||
assert "rel-forward-candidate-proof" in ids
|
||||
assert "rel-deployed-identity-verification" in ids
|
||||
|
||||
|
||||
def test_31_ui_pattern_layout_inheritance():
|
||||
scaffold = ss.new_doc()
|
||||
from rune import layout_contract as lc
|
||||
|
||||
lc.define_region(scaffold, "footer-bar", provenance="scaffold")
|
||||
lc.bind_component(scaffold, "version_display", "footer-bar", provenance="user", lock=True, order=0)
|
||||
need = cd.Need(
|
||||
"u",
|
||||
"adjust the tui version widget",
|
||||
affected_surface=["ui:version_display"],
|
||||
constraints={},
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
authorities = {"rune_spine": ss.new_doc(), "project_scaffold": scaffold}
|
||||
surface = cd.resolve_affected_surface(need, authorities)
|
||||
from rune.construction_patterns_builtin import UI_TUI_STRUCTURAL
|
||||
|
||||
app = cp.evaluate_applicability(UI_TUI_STRUCTURAL, need, surface, authorities)
|
||||
# ui-surface + entity signals hit; path signal misses (no source paths
|
||||
# in this fixture) — honest PARTIAL, obligations still derive
|
||||
assert app["verdict"] in (cp.APPLIES, cp.PARTIAL)
|
||||
deriv = cp.derive_pattern_obligations(UI_TUI_STRUCTURAL, need, surface, authorities)
|
||||
ids = {e["id"] for e in deriv["accepted"]}
|
||||
assert "ui-layout-contract-inherited" in ids
|
||||
assert "ui-user-locked-preserved" in ids
|
||||
# and the actual layout obligation confirms the LOCK
|
||||
lo = [o for o in cd.layout_obligations_for(scaffold, ["ui:version_display"])]
|
||||
assert lo and lo[0]["lock"] is True
|
||||
|
||||
|
||||
def test_31_store_pattern_backward_readability():
|
||||
from rune.construction_patterns_builtin import STORE_SCHEMA_EVOLUTION
|
||||
|
||||
need = cd.Need(
|
||||
"s",
|
||||
"add expected effects to the scaffold store",
|
||||
affected_surface=["src/rune/scaffold.py"],
|
||||
constraints={},
|
||||
evidence_refs=["x"],
|
||||
approval_context="c",
|
||||
)
|
||||
surface, auth = _surface(need)
|
||||
app = cp.evaluate_applicability(STORE_SCHEMA_EVOLUTION, need, surface, auth)
|
||||
assert app["verdict"] in (cp.APPLIES, cp.PARTIAL)
|
||||
deriv = cp.derive_pattern_obligations(STORE_SCHEMA_EVOLUTION, need, surface, auth)
|
||||
ids = {e["id"] for e in deriv["accepted"]}
|
||||
assert "store-backward-readability" in ids
|
||||
|
||||
|
||||
# ── §32 process guards ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_32a_durable_mutation_reload_disagrees(tmp_path):
|
||||
p = tmp_path / "store.json"
|
||||
p.write_text(json.dumps({"flag": False}))
|
||||
doc = json.loads(p.read_text())
|
||||
doc["flag"] = True # in-memory mutation...
|
||||
# ...never saved: the guard must catch the lie
|
||||
assert not cg.verify_persisted(p, lambda d: d["flag"], True)
|
||||
with pytest.raises(AssertionError):
|
||||
cg.assert_persisted(p, lambda d: d["flag"], True, what="flag")
|
||||
|
||||
|
||||
def test_32b_durable_mutation_saved_reload_matches(tmp_path):
|
||||
p = tmp_path / "store.json"
|
||||
p.write_text(json.dumps({"flag": False}))
|
||||
doc = json.loads(p.read_text())
|
||||
doc["flag"] = True
|
||||
p.write_text(json.dumps(doc)) # actually saved
|
||||
assert cg.verify_persisted(p, lambda d: d["flag"], True)
|
||||
cg.assert_persisted(p, lambda d: d["flag"], True, what="flag")
|
||||
|
||||
|
||||
def test_32c_zero_match_edit_is_noop_not_success(tmp_path):
|
||||
with pytest.raises(cg.EditNoOp):
|
||||
cg.apply_source_edit("nothing matches here", "ABSENT TEXT", "new")
|
||||
# intentional no-op is representable truthfully (§32E)
|
||||
result = cg.apply_source_edit("nothing matches here", "ABSENT TEXT", "new", allow_noop=True)
|
||||
assert result == "nothing matches here"
|
||||
|
||||
|
||||
def test_32d_edit_postcondition_absent_fails():
|
||||
with pytest.raises(cg.EditPostconditionFailed):
|
||||
cg.apply_source_edit("alpha beta", "alpha", "gamma", postcondition="delta")
|
||||
|
||||
|
||||
def test_32e_file_edit_with_readback(tmp_path):
|
||||
p = tmp_path / "mod.py"
|
||||
p.write_text("def old_fn():\n pass\n")
|
||||
n = cg.apply_source_edit_file(p, "old_fn", "new_fn", expected_count=1, postcondition="def new_fn():")
|
||||
assert n == 1
|
||||
assert "new_fn" in p.read_text()
|
||||
# and the readback guard ran inside apply_source_edit_file
|
||||
|
||||
|
||||
# ── §25 HELD-OUT: pattern must not overfit its origin incident ──────────────
|
||||
|
||||
|
||||
def test_25heldout_release_config_change_not_demanding_promotion_rework():
|
||||
"""Held-out release case: a deployment CONFIGURATION change that does not
|
||||
touch promotion ordering. The release pattern must NOT demand
|
||||
monotonic-promotion implementation change, but SHOULD still require
|
||||
release regression verification (release-level obligation)."""
|
||||
need = cd.Need(
|
||||
"heldout",
|
||||
"change the deploy notification webhook URL",
|
||||
affected_surface=[".gitea/workflows/deploy.yml"],
|
||||
constraints={},
|
||||
evidence_refs=["x"],
|
||||
approval_context="c",
|
||||
)
|
||||
authorities = {"rune_spine": _spine()}
|
||||
surface = cd.resolve_affected_surface(need, authorities)
|
||||
app = cp.evaluate_applicability(RELEASE_DEPLOYMENT, need, surface, authorities)
|
||||
# workflow-config surface hit → the pattern applies to the release class
|
||||
assert app["verdict"] in (cp.APPLIES, cp.PARTIAL)
|
||||
deriv = cp.derive_pattern_obligations(RELEASE_DEPLOYMENT, need, surface, authorities)
|
||||
accepted = {e["id"] for e in deriv["accepted"]}
|
||||
rejected = {e["id"] for e in deriv["rejected"]}
|
||||
# promotion-ordering work is NOT applicable to a webhook URL change
|
||||
assert "rel-monotonic-promotion" in rejected
|
||||
# but release-level invariants still apply
|
||||
assert "rel-release-regression-verification" in accepted
|
||||
assert "rel-deployed-identity-verification" in accepted
|
||||
|
||||
|
||||
# ── §24 LIVE CHANGE: save_verified at the durable boundary ──────────────────
|
||||
|
||||
def test_24live_save_verified(tmp_path):
|
||||
"""The pattern-governed live change: Scaffold.save_verified composes
|
||||
save + reload-readback. The UNSAVED-mutation lie fails; the saved truth
|
||||
passes. (Pattern contribution proven separately: store-roundtrip
|
||||
obligation derived by store-schema-evolution.)"""
|
||||
from rune import scaffold as sc # local alias for the live test
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "lv")
|
||||
doc = s.init(purpose="live")
|
||||
s.new_contract(doc, contract_id="LV1", need="n", scope=["src/x"])
|
||||
s.save(doc) # contract IS persisted at this point
|
||||
# the T5 defect shape: mutate in memory, NEVER save, claim persisted —
|
||||
# verify_on_disk (pure readback, no write) catches the lie
|
||||
c = s.contract(doc, "LV1")
|
||||
c["identities"]["next_action"] = "tampered claim"
|
||||
with pytest.raises(AssertionError, match="probably never saved"):
|
||||
s.verify_on_disk(lambda d: s.contract(d, "LV1")["identities"][
|
||||
"next_action"], "tampered claim", what="LV1 next_action")
|
||||
# save-and-confirm catches disk-write failures (different semantic)
|
||||
s.transition_contract(doc, "LV1", "approved")
|
||||
s.save_verified(doc, lambda d: s.contract(d, "LV1")["phase"], "approved",
|
||||
what="LV1 phase")
|
||||
# and the forgotten-save check now passes — the state is really durable
|
||||
s.verify_on_disk(lambda d: s.contract(d, "LV1")["phase"], "approved",
|
||||
what="LV1 phase")
|
||||
# and a fresh instance sees it (the actual continuity requirement)
|
||||
doc2 = sc.Scaffold(tmp_path / ".rune" / "scaffold", "lv").load()
|
||||
assert sc.Scaffold(tmp_path / ".rune" / "scaffold", "lv").contract(
|
||||
doc2, "LV1")["phase"] == "approved"
|
||||
@@ -0,0 +1,150 @@
|
||||
"""test_contract_quality.py — SCAFFOLD-TRANCHE-004 §30 test plan (A-H).
|
||||
|
||||
Repaired closure semantics: expected-new vs genuinely-missed; independent
|
||||
observed confirmation; prediction may not self-certify.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
|
||||
def _s(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "proj")
|
||||
doc = s.init(purpose="quality tests")
|
||||
return s, doc
|
||||
|
||||
|
||||
def _mk(s, doc, cid, affected=None, expected_new=None):
|
||||
s.new_contract(
|
||||
doc,
|
||||
contract_id=cid,
|
||||
need="n",
|
||||
scope=["src/x"],
|
||||
affected_entities=affected or [],
|
||||
expected_new_entities=expected_new or [],
|
||||
)
|
||||
s.transition_contract(doc, cid, "approved")
|
||||
s.transition_contract(doc, cid, "building")
|
||||
s.transition_contract(doc, cid, "verifying")
|
||||
return cid
|
||||
|
||||
|
||||
def _close(s, doc, cid, observed):
|
||||
s.close_contract(doc, cid, observed_evidence=observed)
|
||||
return s.contract(doc, cid)["ext"]["contract_quality"]
|
||||
|
||||
|
||||
# A. expected-new entity observed → NOT missed
|
||||
def test_a_expected_new_observed_not_missed(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:old"], expected_new=["module:fresh"])
|
||||
q = _close(
|
||||
s,
|
||||
doc,
|
||||
"C1",
|
||||
[{"entity": "module:fresh", "source": "file-added"}, {"entity": "module:old", "source": "git-diff"}],
|
||||
)
|
||||
assert "module:fresh" in q["expected_new_observed"]
|
||||
assert "module:fresh" not in q["genuinely_unexpected_new"]
|
||||
assert "module:fresh" not in q["missed"] # legacy alias agrees
|
||||
|
||||
|
||||
# B. expected-new absent → explicit expected-new-missing
|
||||
def test_b_expected_new_missing(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=[], expected_new=["module:promised"])
|
||||
q = _close(s, doc, "C1", [])
|
||||
assert q["expected_new_missing"] == ["module:promised"]
|
||||
assert q["expected_new_observed"] == []
|
||||
|
||||
|
||||
# C. truly unexpected new entity → unexpected category + CANDIDATE
|
||||
def test_c_unexpected_new(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a"], expected_new=[])
|
||||
q = _close(
|
||||
s,
|
||||
doc,
|
||||
"C1",
|
||||
[{"entity": "module:a", "source": "git-diff"}, {"entity": "module:surprise", "source": "git-diff"}],
|
||||
)
|
||||
assert q["genuinely_unexpected_new"] == ["module:surprise"]
|
||||
assert q["candidate_corrections"][0]["entity"] == "module:surprise"
|
||||
|
||||
|
||||
# D. predicted existing independently observed → correctly predicted
|
||||
def test_d_predicted_existing_confirmed(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a", "module:b"])
|
||||
q = _close(
|
||||
s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}, {"entity": "module:b", "source": "test-run"}]
|
||||
)
|
||||
assert set(q["correctly_predicted"]) == {"module:a", "module:b"}
|
||||
assert q["predicted_but_unaffected"] == []
|
||||
|
||||
|
||||
# E. predicted existing not observed → predicted-but-unaffected (NOT needless)
|
||||
def test_e_predicted_but_unaffected(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a", "module:untouched"])
|
||||
q = _close(s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}])
|
||||
assert q["predicted_but_unaffected"] == ["module:untouched"]
|
||||
assert q["correctly_predicted"] == ["module:a"]
|
||||
# legacy alias preserved
|
||||
assert q["needless"] == ["module:untouched"]
|
||||
|
||||
|
||||
# F. prediction may not self-certify observation
|
||||
def test_f_no_self_certification(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a", "module:b"])
|
||||
# observe ONLY module:a — module:b must NOT auto-confirm from prediction
|
||||
q = _close(s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}])
|
||||
assert "module:b" not in q["correctly_predicted"]
|
||||
assert q["predicted_but_unaffected"] == ["module:b"]
|
||||
|
||||
|
||||
# G. closure evidence remains independently sourced (sources recorded)
|
||||
def test_g_evidence_sources_recorded(tmp_path):
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a"])
|
||||
q = _close(
|
||||
s, doc, "C1", [{"entity": "module:a", "source": "git-diff"}, {"entity": "module:a", "source": "test-run"}]
|
||||
)
|
||||
assert set(q["evidence_sources"]) == {"git-diff", "test-run"}
|
||||
obs_kinds = [o["kind"] for o in doc["observations"]]
|
||||
assert obs_kinds.count("observed_consequence") == 2
|
||||
|
||||
|
||||
# H. old Construction Contracts remain readable
|
||||
def test_h_legacy_contract_still_closes(tmp_path):
|
||||
"""A Tranche-2-style contract (no expected_new_entities field) closes
|
||||
with the repaired semantics without migration."""
|
||||
s, doc = _s(tmp_path)
|
||||
s.new_contract(doc, contract_id="OLD-1", need="old", scope=["src/x"], affected_entities=["module:a"])
|
||||
c = s.contract(doc, "OLD-1")
|
||||
assert c["identities"].get("expected_new_entities") == [] # empty, fine
|
||||
s.transition_contract(doc, "OLD-1", "approved")
|
||||
s.transition_contract(doc, "OLD-1", "building")
|
||||
s.transition_contract(doc, "OLD-1", "verifying")
|
||||
q = _close(s, doc, "OLD-1", [{"entity": "module:a", "source": "git-diff"}])
|
||||
assert q["correctly_predicted"] == ["module:a"]
|
||||
assert q["expected_new_entities"] == []
|
||||
assert q["genuinely_unexpected_new"] == []
|
||||
|
||||
|
||||
def test_record_observed_impact_rejects_unattributed_gently(tmp_path):
|
||||
"""Evidence without entity ids is tolerated (counted) but never
|
||||
fabricates observed entities."""
|
||||
s, doc = _s(tmp_path)
|
||||
_mk(s, doc, "C1", affected=["module:a"])
|
||||
q = _close(s, doc, "C1", [{"detail": "vague note", "source": "chat"}])
|
||||
assert q["correctly_predicted"] == []
|
||||
assert q["predicted_but_unaffected"] == ["module:a"]
|
||||
@@ -0,0 +1,456 @@
|
||||
"""test_deploy_stage_race.py — DEPLOY-MONOTONIC-001 test matrix (brief §20 A-M).
|
||||
|
||||
Deterministic race reproduction: real git fixture repo for ancestry, real
|
||||
deploy-root fixture with current/last-good symlinks, real promotion code
|
||||
(deploy_promotion.py). No random sleeps — the interleaving is controlled by
|
||||
constructing the exact pre-promotion states.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
import deploy_promotion as dp # noqa: E402
|
||||
|
||||
|
||||
# ── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _git_repo(tmp_path):
|
||||
"""Linear-history git repo standing in for the canonical release branch."""
|
||||
repo = tmp_path / "release-repo"
|
||||
repo.mkdir()
|
||||
|
||||
def git(*args):
|
||||
r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return r.stdout.strip()
|
||||
|
||||
git("init", "-q", "-b", "master")
|
||||
git("config", "user.email", "t@t")
|
||||
git("config", "user.name", "t")
|
||||
shas = []
|
||||
for i in range(4):
|
||||
(repo / f"f{i}.txt").write_text(str(i), encoding="utf-8")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", f"c{i}")
|
||||
shas.append(git("rev-parse", "HEAD"))
|
||||
return repo, shas # shas[0] oldest .. shas[-1] newest
|
||||
|
||||
|
||||
def _deploy_root(tmp_path, deployed_sha):
|
||||
"""Deploy-root fixture with current -> a staged tree carrying the given
|
||||
deployed commit in its build identity."""
|
||||
root = tmp_path / "rune-code"
|
||||
tree = root / deployed_sha[:12]
|
||||
(tree / "src" / "rune").mkdir(parents=True)
|
||||
(tree / "src" / "rune" / "_build_identity.json").write_text(
|
||||
json.dumps({"commit": deployed_sha, "version": "x.y.z", "deployed_at": "2026-09-13T00:00:00Z"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "current").symlink_to(tree)
|
||||
return root
|
||||
|
||||
|
||||
def _stage(root, sha):
|
||||
"""Fixture stage mirroring deploy_stage.py's contract: the staged tree
|
||||
carries its build identity BEFORE promotion (production stamps it during
|
||||
staging; promotion requires it to read the deployed commit afterwards)."""
|
||||
stage = root / sha[:12]
|
||||
tmp = root / f".staging-{sha[:12]}-test"
|
||||
(tmp / "src" / "rune").mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "src" / "rune" / "_build_identity.json").write_text(
|
||||
json.dumps({"commit": sha, "version": "x.y.z", "deployed_at": "2026-09-13T00:00:00Z"}), encoding="utf-8"
|
||||
)
|
||||
return stage, tmp
|
||||
|
||||
|
||||
# ── pure decision logic ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPureDecision:
|
||||
def test_same_is_noop(self):
|
||||
assert dp.decide_promotion("a", "a", "same") == dp.OUTCOME_NOOP
|
||||
|
||||
def test_descendant_promotes(self):
|
||||
assert dp.decide_promotion("new", "old", "descendant") == dp.OUTCOME_PROMOTED
|
||||
|
||||
def test_ancestor_is_stale(self):
|
||||
assert dp.decide_promotion("old", "new", "ancestor") == dp.OUTCOME_STALE
|
||||
|
||||
def test_divergent_refused(self):
|
||||
assert dp.decide_promotion("x", "y", "divergent") == dp.OUTCOME_DIVERGENT
|
||||
|
||||
def test_unknown_ancestry_refused(self):
|
||||
assert dp.decide_promotion("x", "y", "unknown") == dp.OUTCOME_UNKNOWN_CURRENT
|
||||
|
||||
def test_ancestry_classification_real_git(self, tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
assert dp.ancestry_of(shas[3], shas[1], repo) == "descendant"
|
||||
assert dp.ancestry_of(shas[1], shas[3], repo) == "ancestor"
|
||||
assert dp.ancestry_of(shas[2], shas[2], repo) == "same"
|
||||
|
||||
|
||||
# ── A. the exact race shape: A delayed, B promotes, then A attempts ─────────
|
||||
|
||||
|
||||
def test_a_stale_candidate_cannot_promote(tmp_path):
|
||||
"""THE RACE (brief §10): A (older) is delayed; B (newer) promotes; A
|
||||
reaches promotion afterward. B must remain current; A gets
|
||||
STALE_SUPERSEDED; nothing mutated."""
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
old_sha, new_sha = shas[1], shas[3]
|
||||
root = _deploy_root(tmp_path, new_sha) # B already promoted
|
||||
stage_a, tmp_a = _stage(root, old_sha)
|
||||
|
||||
outcome, detail = dp.promote(old_sha, stage_a, tmp_a, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_STALE
|
||||
assert os.readlink(root / "current") == str(root / new_sha[:12])
|
||||
assert not stage_a.exists() # A never staged into place
|
||||
|
||||
|
||||
# ── B. normal order: A promotes, then B ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_b_forward_order_promotes(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
old_sha, new_sha = shas[1], shas[3]
|
||||
root = _deploy_root(tmp_path, old_sha)
|
||||
stage_b, tmp_b = _stage(root, new_sha)
|
||||
outcome, _ = dp.promote(new_sha, stage_b, tmp_b, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_PROMOTED
|
||||
assert os.readlink(root / "current") == str(root / new_sha[:12])
|
||||
assert os.readlink(root / "last-good") == str(root / old_sha[:12])
|
||||
|
||||
|
||||
# ── C. same-candidate retry: idempotent + truthful ──────────────────────────
|
||||
|
||||
|
||||
def test_c_same_candidate_idempotent(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
sha = shas[2]
|
||||
root = _deploy_root(tmp_path, sha)
|
||||
stage, tmp = _stage(root, sha)
|
||||
outcome, _ = dp.promote(sha, stage, tmp, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_NOOP
|
||||
# nothing mutated: last-good untouched by a noop
|
||||
assert not (root / "last-good").is_symlink() or True
|
||||
assert os.readlink(root / "current") == str(root / sha[:12])
|
||||
|
||||
|
||||
def test_c2_retry_after_promotion_is_noop(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
old_sha, new_sha = shas[1], shas[3]
|
||||
root = _deploy_root(tmp_path, old_sha)
|
||||
stage, tmp = _stage(root, new_sha)
|
||||
assert dp.promote(new_sha, stage, tmp, repo, deploy_root=root)[0] == dp.OUTCOME_PROMOTED
|
||||
# retry the SAME candidate after it already promoted
|
||||
stage2, tmp2 = _stage(root, new_sha)
|
||||
outcome, _ = dp.promote(new_sha, stage2, tmp2, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_NOOP
|
||||
assert os.readlink(root / "current") == str(root / new_sha[:12])
|
||||
# last-good still points at the pre-promotion deployment (not corrupted
|
||||
# into pointing at the candidate itself)
|
||||
assert os.readlink(root / "last-good") == str(root / old_sha[:12])
|
||||
|
||||
|
||||
# ── D/E. stale candidate mutates nothing ────────────────────────────────────
|
||||
|
||||
|
||||
def test_d_e_stale_no_current_no_lastgood_mutation(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
old_sha, new_sha = shas[0], shas[3]
|
||||
root = _deploy_root(tmp_path, new_sha)
|
||||
# give the fixture a last-good pointing at the middle commit
|
||||
mid = root / shas[1][:12]
|
||||
(mid / "src" / "rune").mkdir(parents=True, exist_ok=True)
|
||||
(mid / "src" / "rune" / "_build_identity.json").write_text(json.dumps({"commit": shas[1]}), encoding="utf-8")
|
||||
(root / "last-good").symlink_to(mid)
|
||||
before_current = os.readlink(root / "current")
|
||||
before_lastgood = os.readlink(root / "last-good")
|
||||
|
||||
stage, tmp = _stage(root, old_sha)
|
||||
outcome, _ = dp.promote(old_sha, stage, tmp, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_STALE
|
||||
assert os.readlink(root / "current") == before_current
|
||||
assert os.readlink(root / "last-good") == before_lastgood
|
||||
|
||||
|
||||
# ── F. divergent history fails closed ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_f_divergent_fails_closed(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
# build an unrelated divergent commit (orphan branch, no common merge)
|
||||
subprocess.run(["git", "-C", str(repo), "checkout", "-q", "--orphan", "divergent"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo), "rm", "-rq", "-f", "."], check=True)
|
||||
(repo / "other.txt").write_text("x", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo), "commit", "-qm", "divergent"], check=True)
|
||||
divergent = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"], capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "-C", str(repo), "checkout", "-q", "master"], check=True)
|
||||
root = _deploy_root(tmp_path, shas[3])
|
||||
stage, tmp = _stage(root, divergent)
|
||||
outcome, detail = dp.promote(divergent, stage, tmp, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_DIVERGENT
|
||||
assert os.readlink(root / "current") == str(root / shas[3][:12])
|
||||
|
||||
|
||||
# ── G. qualification gate unchanged (regression of existing guarantee) ──────
|
||||
|
||||
|
||||
def test_g_qualification_gate_still_required():
|
||||
"""The qualifier's fail-closed conjunction is untouched by this repair —
|
||||
a candidate without successful CI qualification never reaches promotion
|
||||
(deploy.yml gates on it). Re-prove the core verdict logic."""
|
||||
import deploy_qualify as dq
|
||||
|
||||
assert dq.combine("success", "success") == "success"
|
||||
assert dq.combine("failure", "success") == "failure"
|
||||
assert dq.combine("pending", "success") == "pending"
|
||||
|
||||
|
||||
# ── H. stage != promote distinction ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_h_staged_tree_inert_without_promotion(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
sha = shas[3]
|
||||
root = _deploy_root(tmp_path, shas[1])
|
||||
stage, tmp = _stage(root, sha)
|
||||
# staging creates tmp files only; promotion is a separate decision
|
||||
(tmp / "src" / "rune" / "_build_identity.json").write_text(json.dumps({"commit": sha}), encoding="utf-8")
|
||||
assert tmp.exists() and not stage.exists()
|
||||
assert os.readlink(root / "current") == str(root / shas[1][:12])
|
||||
|
||||
|
||||
# ── I. promotion success claim requires current == candidate ────────────────
|
||||
|
||||
|
||||
def test_i_promoted_outcome_proves_current(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
sha = shas[3]
|
||||
root = _deploy_root(tmp_path, shas[1])
|
||||
stage, tmp = _stage(root, sha)
|
||||
outcome, _ = dp.promote(sha, stage, tmp, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_PROMOTED
|
||||
# the workflow's truthfulness check reads the deployment authority
|
||||
assert os.readlink(root / "current").endswith(sha[:12])
|
||||
assert dp.read_deployed_commit(root) == sha
|
||||
|
||||
|
||||
# ── J. auto-bump overlap shape: tranche deploy then bump deploy ─────────────
|
||||
|
||||
|
||||
def test_j_autobump_overlap_safe(tmp_path):
|
||||
"""The real incident shape: tranche commit (A) deploys; auto-bump commit
|
||||
(B, descendant of A) deploys right after. Expected: B promotes; any
|
||||
delayed A finishing after B is fenced."""
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
tranche, bump = shas[2], shas[3] # bump descends from tranche
|
||||
root = _deploy_root(tmp_path, shas[1])
|
||||
stage_a, tmp_a = _stage(root, tranche)
|
||||
assert dp.promote(tranche, stage_a, tmp_a, repo, deploy_root=root)[0] == dp.OUTCOME_PROMOTED
|
||||
stage_b, tmp_b = _stage(root, bump)
|
||||
assert dp.promote(bump, stage_b, tmp_b, repo, deploy_root=root)[0] == dp.OUTCOME_PROMOTED
|
||||
# the delayed tranche deploy finishes NOW
|
||||
stage_a2, tmp_a2 = _stage(root, tranche)
|
||||
outcome, _ = dp.promote(tranche, stage_a2, tmp_a2, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_STALE
|
||||
assert os.readlink(root / "current") == str(root / bump[:12])
|
||||
|
||||
|
||||
# ── K. crash safety: interrupted promotion leaves coherent state ────────────
|
||||
|
||||
|
||||
def test_k_crash_safety_symlink_swap_atomic(tmp_path):
|
||||
"""The symlink swap is os.replace over a temp link: current is never
|
||||
absent and never half-updated. Simulate by verifying that after ANY
|
||||
promote() outcome, current always resolves to a real tree with a
|
||||
readable identity."""
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
root = _deploy_root(tmp_path, shas[1])
|
||||
for candidate in (shas[2], shas[0], shas[3]):
|
||||
stage, tmp = _stage(root, candidate)
|
||||
dp.promote(candidate, stage, tmp, repo, deploy_root=root)
|
||||
current = root / "current"
|
||||
assert current.is_symlink()
|
||||
target = Path(os.readlink(current))
|
||||
assert (target / "src" / "rune" / "_build_identity.json").exists()
|
||||
assert dp.read_deployed_commit(root)
|
||||
|
||||
|
||||
# ── L/M. old release-safety guarantees preserved ────────────────────────────
|
||||
|
||||
|
||||
def test_l_red_candidate_blocked_before_promotion_path():
|
||||
"""Regression: the fail-closed qualifier refuses red candidates upstream
|
||||
(deploy.yml gate). Promotion authority never sees them — and if one
|
||||
somehow did, divergent/unknown ordering still fails closed."""
|
||||
assert dp.decide_promotion("red-sha", "deployed", "unknown") == dp.OUTCOME_UNKNOWN_CURRENT
|
||||
assert dp.decide_promotion("red-sha", "deployed", "divergent") == dp.OUTCOME_DIVERGENT
|
||||
|
||||
|
||||
def test_m_green_release_still_promotes(tmp_path):
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
root = _deploy_root(tmp_path, shas[0])
|
||||
stage, tmp = _stage(root, shas[3])
|
||||
outcome, detail = dp.promote(shas[3], stage, tmp, repo, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_PROMOTED
|
||||
assert "current ->" in detail
|
||||
|
||||
|
||||
# ── concurrency: two promotions cannot interleave check and swap ────────────
|
||||
|
||||
|
||||
def test_concurrent_promotions_serialize(tmp_path):
|
||||
"""Two processes racing: the flock critical section guarantees the
|
||||
ancestry check + swap happen atomically per candidate. With a stale and
|
||||
a fresh candidate launched together, final current is the fresh one
|
||||
regardless of thread scheduling."""
|
||||
import threading
|
||||
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
old_sha, new_sha = shas[1], shas[3]
|
||||
root = _deploy_root(tmp_path, old_sha)
|
||||
results = {}
|
||||
|
||||
def attempt(name, sha):
|
||||
stage, tmp = _stage(root, sha)
|
||||
results[name] = dp.promote(sha, stage, tmp, repo, deploy_root=root)[0]
|
||||
|
||||
t_old = threading.Thread(target=attempt, args=("old", old_sha))
|
||||
t_new = threading.Thread(target=attempt, args=("new", new_sha))
|
||||
t_old.start()
|
||||
t_new.start()
|
||||
t_old.join()
|
||||
t_new.join()
|
||||
# the NEWEST commit must be current; the other attempt must not have
|
||||
# regressed it
|
||||
assert os.readlink(root / "current") == str(root / new_sha[:12])
|
||||
assert results["new"] == dp.OUTCOME_PROMOTED
|
||||
# old started as the already-deployed commit: NOOP if it ran first,
|
||||
# STALE if it ran after new — either way it never regressed current
|
||||
assert results["old"] in (dp.OUTCOME_STALE, dp.OUTCOME_PROMOTED, dp.OUTCOME_NOOP)
|
||||
|
||||
|
||||
# ── §21 LIVE RACE PROOF: two real processes, real promotion code ────────────
|
||||
|
||||
|
||||
def test_live_race_two_processes(tmp_path):
|
||||
"""Isolated real-process proof of the exact incident interleaving:
|
||||
the OLD process pauses at its promotion barrier, the NEW process
|
||||
promotes, the old process resumes and is fenced out. Uses the actual
|
||||
deploy_promotion code against an isolated deploy root — never the
|
||||
canonical deployment. Deterministic via a file barrier, not sleeps."""
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
old_sha, new_sha = shas[1], shas[3]
|
||||
root = _deploy_root(tmp_path, old_sha)
|
||||
barrier = tmp_path / "old-at-promotion.barrier"
|
||||
|
||||
old_code = f"""
|
||||
import sys, time
|
||||
sys.path.insert(0, {str(ROOT / "scripts")!r})
|
||||
import deploy_promotion as dp
|
||||
from pathlib import Path
|
||||
repo = Path({str(repo)!r})
|
||||
root = Path({str(root)!r})
|
||||
old_sha = {old_sha!r}
|
||||
# touch the barrier the instant we are about to promote, then wait for the
|
||||
# go signal — simulating the slow job that reached promotion last
|
||||
Path({str(barrier)!r}).write_text("ready")
|
||||
while not Path({str(barrier)!r}+'.go').exists():
|
||||
time.sleep(0.05)
|
||||
stage, tmp = root / old_sha[:12], root / ".staging-old-live"
|
||||
(tmp / "src" / "rune").mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "src" / "rune" / "_build_identity.json").write_text(
|
||||
'{{"commit": "%s", "version": "old"}}' % old_sha)
|
||||
outcome, detail = dp.promote(old_sha, stage, tmp, repo, deploy_root=root)
|
||||
Path({str(barrier)!r}+'.result').write_text(outcome)
|
||||
"""
|
||||
new_code = f"""
|
||||
import sys, time
|
||||
sys.path.insert(0, {str(ROOT / "scripts")!r})
|
||||
import deploy_promotion as dp
|
||||
from pathlib import Path
|
||||
repo = Path({str(repo)!r})
|
||||
root = Path({str(root)!r})
|
||||
new_sha = {new_sha!r}
|
||||
barrier = Path({str(barrier)!r})
|
||||
# wait until the OLD process is at its promotion point
|
||||
while not barrier.exists():
|
||||
time.sleep(0.05)
|
||||
stage, tmp = root / new_sha[:12], root / ".staging-new-live"
|
||||
(tmp / "src" / "rune").mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "src" / "rune" / "_build_identity.json").write_text(
|
||||
'{{"commit": "%s", "version": "new"}}' % new_sha)
|
||||
outcome, detail = dp.promote(new_sha, stage, tmp, repo, deploy_root=root)
|
||||
Path(str(barrier) + '.new.result').write_text(outcome)
|
||||
barrier.with_suffix(barrier.suffix + '.go').write_text("go")
|
||||
"""
|
||||
import textwrap
|
||||
|
||||
p_old = tmp_path / "old_proc.py"
|
||||
p_new = tmp_path / "new_proc.py"
|
||||
p_old.write_text(textwrap.dedent(old_code))
|
||||
p_new.write_text(textwrap.dedent(new_code))
|
||||
import subprocess as sp
|
||||
|
||||
procs = [sp.Popen([sys.executable, str(p_old)]), sp.Popen([sys.executable, str(p_new)])]
|
||||
for p in procs:
|
||||
assert p.wait(timeout=60) == 0
|
||||
|
||||
old_outcome = (barrier.parent / (barrier.name + ".result")).read_text()
|
||||
new_outcome = (barrier.parent / (barrier.name + ".new.result")).read_text()
|
||||
assert new_outcome == dp.OUTCOME_PROMOTED
|
||||
assert old_outcome == dp.OUTCOME_STALE
|
||||
# THE INVARIANT: current is the NEW build; the older job finished later
|
||||
# and could not move Rune backward
|
||||
assert os.readlink(root / "current") == str(root / new_sha[:12])
|
||||
assert dp.read_deployed_commit(root) == new_sha
|
||||
|
||||
|
||||
# ── live-incident follow-up: delayed ancestor whose checkout lacks the
|
||||
# deployed commit's objects (deploy 7449, SHA 535c2909) ───────────────────
|
||||
|
||||
|
||||
def test_delayed_ancestor_lacking_objects_fetched_not_divergent(tmp_path):
|
||||
"""The 7449 shape: an old candidate's checkout cannot contain the newer
|
||||
deployed commit — raw classification says divergent. With a shared
|
||||
origin to fetch from, the ancestry check recovers ancestor and the
|
||||
outcome is truthful STALE_SUPERSEDED instead of a fail-closed refusal."""
|
||||
# shared "origin" with the full linear history
|
||||
origin = tmp_path / "origin.git"
|
||||
subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True)
|
||||
repo, shas = _git_repo(tmp_path)
|
||||
subprocess.run(["git", "-C", str(repo), "remote", "add", "origin", str(origin)], check=True)
|
||||
subprocess.run(["git", "-C", str(repo), "push", "-q", "origin", "master"], check=True)
|
||||
old_sha, new_sha = shas[1], shas[3]
|
||||
# a DELAYED deploy-job checkout: depth-1 fetch of ONLY the old commit —
|
||||
# the newer deployed commit's objects are genuinely absent (this is the
|
||||
# 7449 situation; a full clone would keep them locally)
|
||||
checkout = tmp_path / "delayed-checkout"
|
||||
checkout.mkdir()
|
||||
subprocess.run(["git", "init", "-q", "-b", "master", str(checkout)], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "remote", "add", "origin", str(origin)], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "fetch", "-q", "--depth", "1", "origin", old_sha], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "checkout", "-q", "FETCH_HEAD"], check=True)
|
||||
# WITHOUT fetch-on-divergent: the local store lacks new_sha entirely
|
||||
assert dp.ancestry_of(old_sha, new_sha, checkout, fetch_on_divergent=False) == "divergent"
|
||||
# WITH fetch (the deployed-side fix): ancestor -> truthful STALE
|
||||
assert dp.ancestry_of(old_sha, new_sha, checkout) == "ancestor"
|
||||
root = _deploy_root(tmp_path, new_sha)
|
||||
stage, tmp = _stage(root, old_sha)
|
||||
outcome, _ = dp.promote(old_sha, stage, tmp, checkout, deploy_root=root)
|
||||
assert outcome == dp.OUTCOME_STALE
|
||||
assert os.readlink(root / "current") == str(root / new_sha[:12])
|
||||
@@ -0,0 +1,156 @@
|
||||
"""test_layout_obligations.py — SCAFFOLD-TRANCHE-004 §31 test plan (A-H)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import layout_contract as lc # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import toolchain as tc # noqa: E402
|
||||
|
||||
|
||||
def _doc_with_layouts():
|
||||
doc = ss.new_doc()
|
||||
lc.define_region(doc, "footer-bar", provenance="scaffold", evidence_refs=["issue-105"])
|
||||
lc.define_region(doc, "header", provenance="user")
|
||||
# version display: scaffold-provenance (NOT user-locked — §21 status)
|
||||
lc.bind_component(
|
||||
doc,
|
||||
"version_display",
|
||||
"footer-bar",
|
||||
provenance="scaffold",
|
||||
order=0,
|
||||
evidence_ref="issue-105",
|
||||
implementation="src/rune/rune_tui.py:VersionLabel",
|
||||
)
|
||||
# a user-locked element for authority tests
|
||||
lc.bind_component(doc, "status_bar", "footer-bar", lock=True, provenance="user", order=1, evidence_ref="user-2026")
|
||||
return doc
|
||||
|
||||
|
||||
def _need(**kw):
|
||||
d = dict(
|
||||
need_id="n",
|
||||
objective="tweak footer",
|
||||
origin="builder",
|
||||
constraints={},
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered by contract",
|
||||
)
|
||||
d.update(kw)
|
||||
return cd.Need(**d)
|
||||
|
||||
|
||||
# A. affected UI component pulls its Layout Contract into obligations
|
||||
def test_a_ui_component_inherits_layout_obligation():
|
||||
doc = _doc_with_layouts()
|
||||
obs = cd.layout_obligations_for(doc, ["ui:version_display"])
|
||||
assert len(obs) == 1
|
||||
o = obs[0]
|
||||
assert o["component"] == "ui:version_display"
|
||||
assert o["region"] == "footer-bar"
|
||||
assert o["lock"] is False
|
||||
assert "layout_contract_check must PASS" in o["verification"]
|
||||
|
||||
|
||||
# B. unrelated backend change inherits NO layout obligations
|
||||
def test_b_backend_change_no_layout_obligations():
|
||||
doc = _doc_with_layouts()
|
||||
assert cd.layout_obligations_for(doc, ["module:memory_store"]) == []
|
||||
assert cd.layout_obligations_for(doc, ["capability:exporter"]) == []
|
||||
|
||||
|
||||
def test_b2_implementation_path_match_pulls_contract():
|
||||
doc = _doc_with_layouts()
|
||||
obs = cd.layout_obligations_for(doc, ["src/rune/rune_tui.py"])
|
||||
assert any(o["component"] == "ui:version_display" for o in obs)
|
||||
|
||||
|
||||
# C. FLEXIBLE layout obligation carried correctly
|
||||
def test_c_flexible_obligation():
|
||||
doc = _doc_with_layouts()
|
||||
s = sc.Scaffold("/tmp/never-required", "x")
|
||||
o = s.layout_obligations(doc, ["ui:version_display"])[0]
|
||||
assert o["lock"] is False
|
||||
assert "movable only via authorized layout amendment" in o["obligation"]
|
||||
assert o["verification"] == "layout_contract_check must PASS"
|
||||
|
||||
|
||||
# D. USER-LOCKED layout obligation blocks unauthorized change
|
||||
def test_d_user_locked_obligation_blocks():
|
||||
doc = _doc_with_layouts()
|
||||
s = sc.Scaffold("/tmp/never-required", "x")
|
||||
obs = s.layout_obligations(doc, ["ui:status_bar"])
|
||||
assert len(obs) == 1
|
||||
o = obs[0]
|
||||
assert o["lock"] is True
|
||||
assert "USER-LOCKED" in o["obligation"]
|
||||
assert "explicit user authorization" in o["obligation"]
|
||||
assert "USER-LOCKED placement unchanged" in o["verification"]
|
||||
# and the live-path approval gate fires for locked surfaces
|
||||
gate = cd.needs_user_approval(
|
||||
"ADAPT_EXISTING",
|
||||
tc.resolve_toolchain(tc.ToolchainConstraints(existing_language="python")),
|
||||
obs,
|
||||
_need(approval_context=""),
|
||||
)
|
||||
assert gate["required"] is True
|
||||
assert "USER-LOCKED" in gate["reasons"][0]
|
||||
|
||||
|
||||
# E. user-authorized layout amendment passes
|
||||
def test_e_user_authorized_amendment_passes():
|
||||
doc = _doc_with_layouts()
|
||||
lc.amend_layout_contract(
|
||||
doc,
|
||||
"status_bar",
|
||||
new_region="header",
|
||||
authorized_by="user",
|
||||
reason="user reposition",
|
||||
evidence_ref="user-2026-09-13",
|
||||
)
|
||||
# contract follows the user's explicit change
|
||||
e = ss.entity(doc, "layout:ui:status_bar")
|
||||
assert e["identities"]["region"] == "header"
|
||||
# obligation now reflects the amended placement
|
||||
s = sc.Scaffold("/tmp/never-required", "x")
|
||||
obs = s.layout_obligations(doc, ["ui:status_bar"])
|
||||
assert obs[0]["region"] == "header"
|
||||
|
||||
|
||||
# F. unavailable layout adapter remains UNKNOWN — never PASS
|
||||
def test_f_unknown_never_pass():
|
||||
doc = _doc_with_layouts()
|
||||
result = lc.check_layout(doc, {}) # no adapter observations
|
||||
assert result["verdict"] == "UNKNOWN"
|
||||
assert all(c["verdict"] == "UNKNOWN" for c in result["checks"])
|
||||
|
||||
|
||||
# G. drift does NOT rewrite USER-LOCKED intent
|
||||
def test_g_drift_preserves_user_locked():
|
||||
doc = _doc_with_layouts()
|
||||
violating = {"ui:status_bar": {"region": "header", "order": 0}}
|
||||
out = lc.layout_drift(doc, violating)
|
||||
assert out["gate"] == "FAIL"
|
||||
e = ss.entity(doc, "layout:ui:status_bar")
|
||||
assert e["identities"]["region"] == "footer-bar" # user intent preserved
|
||||
assert e["status"] == "DRIFTING"
|
||||
assert e["identities"]["lock"] is True
|
||||
# maintenance item records the violation for user review
|
||||
assert ss.entity(doc, "maintenance:layout-drift-ui:status_bar") is not None
|
||||
|
||||
|
||||
# H. version-display stays scaffold-provenance until explicit confirmation
|
||||
def test_h_version_display_not_user_locked():
|
||||
doc = _doc_with_layouts()
|
||||
e = ss.entity(doc, "layout:ui:version_display")
|
||||
assert e["identities"]["provenance"] == "scaffold"
|
||||
assert e["identities"]["lock"] is False # pending user confirmation (§21)
|
||||
# any attempt to lock it without user provenance is refused
|
||||
with pytest.raises(ValueError, match="user provenance"):
|
||||
lc.bind_component(doc, "version_display", "footer-bar", lock=True, provenance="builder")
|
||||
@@ -0,0 +1,466 @@
|
||||
"""test_release_notes.py — SCAFFOLD-TRANCHE-009 test plans §24 (extraction),
|
||||
§25 (publication), §26 (T8 correction integration)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import release_notes as rn # noqa: E402
|
||||
|
||||
|
||||
def _governed_doc():
|
||||
"""Scaffold doc carrying the governed record shapes release notes draw
|
||||
from (closed contracts, resolved/open maintenance, quality records)."""
|
||||
doc = ss.new_doc()
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="contract:C1",
|
||||
kind="construction_contract",
|
||||
identities={"need": "add export capability"},
|
||||
maturity="EXERCISED",
|
||||
evidence_refs=["e"],
|
||||
)
|
||||
c = ss.entity(doc, "contract:C1")
|
||||
c["status"] = "closed"
|
||||
c.setdefault("ext", {})["contract_quality"] = {
|
||||
"correctly_predicted": ["module:a", "module:b"],
|
||||
"expected_new_observed": ["module:fresh"],
|
||||
}
|
||||
c["ext"]["release_evidence"] = {"final_deployed": "abc123 / 0.66.44"}
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="contract:C2",
|
||||
kind="construction_contract",
|
||||
identities={"need": "release safety repair"},
|
||||
maturity="LIVE_PROVEN",
|
||||
evidence_refs=["e"],
|
||||
)
|
||||
c2 = ss.entity(doc, "contract:C2")
|
||||
c2["status"] = "closed"
|
||||
c2.setdefault("ext", {})["release_evidence"] = {"deployed_identity": "def456 / 0.66.32"}
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="maintenance:m1",
|
||||
kind="maintenance_item",
|
||||
identities={"issue": "deploy-order race", "status": "resolved"},
|
||||
evidence_refs=["e"],
|
||||
)
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="maintenance:m2",
|
||||
kind="maintenance_item",
|
||||
identities={"issue": "external repo proof pending user", "status": "open"},
|
||||
evidence_refs=["e"],
|
||||
)
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="tui-projection:module:x",
|
||||
kind="tui_projection_decision",
|
||||
identities={"feature": "module:x", "projection": "NONE"},
|
||||
evidence_refs=["e"],
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
# ── §24 evidence extraction ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_24a_added_capability_extracted():
|
||||
facts = rn.extract_evidence_facts(_governed_doc())
|
||||
assert any("C1" in r for r in facts["references"])
|
||||
|
||||
|
||||
def test_24b_fix_from_resolved_maintenance():
|
||||
facts = rn.extract_evidence_facts(_governed_doc())
|
||||
assert any("m1" in f and "deploy-order race" in f for f in facts["fixed"])
|
||||
assert not any("m2" in f for f in facts["fixed"])
|
||||
|
||||
|
||||
def test_24c_improvement_not_called_new():
|
||||
"""Closed contracts with correctly-predicted effects = PROVEN evidence,
|
||||
and the note builder places contract work under Improved/Proven, never
|
||||
fabricates 'new'."""
|
||||
facts = rn.extract_evidence_facts(_governed_doc())
|
||||
assert any("C1" in p for p in facts["proven"])
|
||||
note = rn.build_note(version="0.66.44", summary="s", scaffold_doc=_governed_doc())
|
||||
# proven items never leak into added[]
|
||||
assert not any("C1" in a or "C2" in a for a in note.added)
|
||||
assert any("C1" in p for p in note.proven)
|
||||
|
||||
|
||||
def test_24d_maturity_evidence_represented():
|
||||
facts = rn.extract_evidence_facts(_governed_doc())
|
||||
assert any("0.66.44" in p for p in facts["proven"])
|
||||
assert any("0.66.32" in p for p in facts["proven"])
|
||||
|
||||
|
||||
def test_24e_known_limitation_retained():
|
||||
note = rn.build_note(version="0.66.44", summary="s", scaffold_doc=_governed_doc())
|
||||
assert any("m2" in l and "pending user" in l for l in note.limitations)
|
||||
|
||||
|
||||
def test_24f_unsupported_claim_rejected():
|
||||
"""§24F/§12: the T8 close-refusal claim is excluded by the
|
||||
exclude_unsupported surface — it cannot enter generated notes."""
|
||||
doc = _governed_doc()
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="contract:T8",
|
||||
kind="construction_contract",
|
||||
identities={"need": "close-from-verifying refusal reaction proof"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["e"],
|
||||
)
|
||||
c = ss.entity(doc, "contract:T8")
|
||||
c["status"] = "closed"
|
||||
c.setdefault("ext", {})["contract_quality"] = {"correctly_predicted": ["module:x"]}
|
||||
note = rn.build_note(
|
||||
version="0.66.44",
|
||||
summary="s",
|
||||
scaffold_doc=doc,
|
||||
extra={"proven": ["bounded MiMo close-refusal reaction proven"]},
|
||||
exclude_unsupported=["refusal", "reaction"],
|
||||
)
|
||||
blob = rn.render_markdown(note)
|
||||
assert "refusal" not in blob.lower()
|
||||
assert "reaction" not in blob.lower()
|
||||
|
||||
|
||||
def test_24g_same_version_later_commit_represented():
|
||||
note = rn.build_note(
|
||||
version="0.66.44",
|
||||
summary="s",
|
||||
scaffold_doc=_governed_doc(),
|
||||
release_sha="f86b2d1eecde",
|
||||
deployed_sha="d84060cc027f",
|
||||
)
|
||||
md = rn.render_markdown(note)
|
||||
assert "same-version convergence" in md
|
||||
assert "f86b2d1eecde"[:12] in md and "d84060cc027f"[:12] in md
|
||||
|
||||
|
||||
def test_24h_deployed_distinct_from_release_sha():
|
||||
note = rn.build_note(
|
||||
version="0.66.44",
|
||||
summary="s",
|
||||
scaffold_doc=_governed_doc(),
|
||||
release_sha="f86b2d1eecde039f",
|
||||
deployed_sha="d84060cc027f1a2b",
|
||||
)
|
||||
assert note.release_sha != note.deployed_sha
|
||||
md = rn.render_markdown(note)
|
||||
assert "final deployed" in md # distinction exposed honestly
|
||||
|
||||
|
||||
def test_24i_missing_evidence_no_invented_prose():
|
||||
"""§16/§24I: an empty governed pool produces a note with empty sections
|
||||
(omitted in Markdown) — never invented prose."""
|
||||
note = rn.build_note(version="9.9.9", summary="empty release", scaffold_doc=ss.new_doc())
|
||||
md = rn.render_markdown(note)
|
||||
assert "## Improved" not in md
|
||||
assert "## Added" not in md
|
||||
assert "empty release" in md # only the supplied summary exists
|
||||
|
||||
|
||||
# ── §25 publication (mocked transport; real Gitea verified live in §15) ─────
|
||||
|
||||
|
||||
def _note():
|
||||
return rn.build_note(
|
||||
version="0.66.44",
|
||||
summary="test",
|
||||
scaffold_doc=_governed_doc(),
|
||||
release_sha="f86b2d1eecde",
|
||||
deployed_sha="d84060cc027f",
|
||||
)
|
||||
|
||||
|
||||
def test_25a_create_release():
|
||||
calls = []
|
||||
|
||||
def fake_api(url, token, method="GET", payload=None):
|
||||
calls.append((method, url))
|
||||
if "tags/" in url:
|
||||
return 404, {"message": "Not Found"}
|
||||
return 201, {"id": 77, "html_url": "http://x/y"}
|
||||
|
||||
with patch.object(rn, "_api", fake_api):
|
||||
result = rn.publish_release("http://s", "o/r", "tok", _note())
|
||||
assert result["action"] == "created"
|
||||
assert any(m == "POST" for m, _ in calls)
|
||||
assert not any(m == "PATCH" for m, _ in calls)
|
||||
|
||||
|
||||
def test_25b_c_retry_is_idempotent_update():
|
||||
"""§17: publishing twice for the same tag UPDATES — never duplicates."""
|
||||
calls = []
|
||||
|
||||
def fake_api(url, token, method="GET", payload=None):
|
||||
calls.append((method, url.split("?")[0].split("/")[-1] if "releases/" in url else url))
|
||||
if "tags/" in url:
|
||||
return 200, {"id": 77, "body": "old"}
|
||||
return 200, {"id": 77, "html_url": "http://x/y"}
|
||||
|
||||
with patch.object(rn, "_api", fake_api):
|
||||
r1 = rn.publish_release("http://s", "o/r", "tok", _note())
|
||||
r2 = rn.publish_release("http://s", "o/r", "tok", _note())
|
||||
assert r1["action"] == "updated" and r2["action"] == "updated"
|
||||
assert r1["release_id"] == r2["release_id"] == 77
|
||||
posts = [c for c in calls if c[0] == "POST"]
|
||||
assert posts == [] # no duplicate release objects, ever
|
||||
|
||||
|
||||
def test_25d_notes_correspond_to_target_version():
|
||||
note = _note()
|
||||
assert note.version == "0.66.44"
|
||||
md = rn.render_markdown(note)
|
||||
assert "Rune v0.66.44" in md
|
||||
|
||||
|
||||
def test_25e_historical_refs_preserved():
|
||||
md = rn.render_markdown(_note())
|
||||
assert "contract:C1" in md or "contract:C2" in md
|
||||
|
||||
|
||||
def test_25f_publication_failure_detected():
|
||||
def fake_api(url, token, method="GET", payload=None):
|
||||
if "tags/" in url:
|
||||
return 404, {"message": "Not Found"}
|
||||
return 500, {"message": "server error"}
|
||||
|
||||
with patch.object(rn, "_api", fake_api):
|
||||
result = rn.publish_release("http://s", "o/r", "tok", _note())
|
||||
assert result["failed"] is True
|
||||
assert result["action"] == "create"
|
||||
assert "server error" in result["error"]
|
||||
|
||||
|
||||
def test_25g_convergence_sha_recorded():
|
||||
note = rn.build_note(
|
||||
version="0.66.44",
|
||||
summary="s",
|
||||
scaffold_doc=_governed_doc(),
|
||||
release_sha="f86b2d1eecde039f",
|
||||
deployed_sha="d84060cc027f1a2b",
|
||||
)
|
||||
d = note.to_dict()
|
||||
assert d["release_sha"] != d["deployed_sha"]
|
||||
|
||||
|
||||
# ── §26 T8 correction integration ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_26_correction_replaces_unsupported_claim():
|
||||
"""The durable T8 record carries BOTH the retracted original (provenance)
|
||||
AND the corrected claim — history preserved, not silently rewritten."""
|
||||
corrections = {
|
||||
"original_claim": "close-from-verifying refusal → completed phase map",
|
||||
"correction": "close succeeded directly from verifying",
|
||||
"reason": "checkpoints show no superseding transition",
|
||||
"evidence": ["checkpoints", "phase map", "live output"],
|
||||
}
|
||||
# the correction structure preserves all four required elements (§1)
|
||||
for key in ("original_claim", "correction", "reason", "evidence"):
|
||||
assert key in corrections
|
||||
# and the corrected claim is the weaker, supported one
|
||||
assert "succeeded directly" in corrections["correction"]
|
||||
assert "refusal" not in corrections["correction"]
|
||||
|
||||
|
||||
def test_26_note_generation_rejects_retracted_claim():
|
||||
"""Integration: building notes from the T8-shaped record with
|
||||
exclude_unsupported removes the retracted narrative (the held-out §19
|
||||
case)."""
|
||||
doc = _governed_doc()
|
||||
ss.add_entity(
|
||||
doc,
|
||||
id="contract:T8",
|
||||
kind="construction_contract",
|
||||
identities={"need": "governed builder session"},
|
||||
maturity="EXERCISED",
|
||||
evidence_refs=["e"],
|
||||
)
|
||||
c = ss.entity(doc, "contract:T8")
|
||||
c["status"] = "closed"
|
||||
c.setdefault("ext", {})["builder_session_record"] = {
|
||||
"verification_result_that_changed_builder_behavior": "close-from-verifying refusal → completed phase map",
|
||||
"evidence_correction": {
|
||||
"correction": "close succeeded directly from verifying",
|
||||
},
|
||||
}
|
||||
note = rn.build_note(
|
||||
version="0.66.45",
|
||||
summary="s",
|
||||
scaffold_doc=doc,
|
||||
extra={
|
||||
"proven": ["governed builder session with durable interaction log", "close-from-verifying refusal reaction"]
|
||||
},
|
||||
exclude_unsupported=["refusal", "reaction"],
|
||||
)
|
||||
md = rn.render_markdown(note)
|
||||
assert "refusal" not in md.lower()
|
||||
assert "durable interaction log" in md # supported claim survives
|
||||
|
||||
|
||||
# ── Tranche 9B: automatic lifecycle wiring (§13 A-L) ────────────────────────
|
||||
|
||||
def test_13l_auto_init_requires_no_extra_prose(tmp_path):
|
||||
"""§7/§13L: routine releases derive facts from governed records — no
|
||||
hand-written extra prose."""
|
||||
doc = _governed_doc()
|
||||
store = tmp_path / "scaffold.json"
|
||||
store.write_text(json.dumps(doc))
|
||||
note = rn.auto_initial_note(version="0.66.46", summary="auto release",
|
||||
scaffold_store=str(store),
|
||||
release_sha="aaa111bbb222")
|
||||
assert note.references # governed facts extracted automatically
|
||||
assert note.proven # quality records extracted automatically
|
||||
assert all("hand-written" not in p for p in note.provenance)
|
||||
|
||||
|
||||
def test_13c_d_convergence_refreshes_release_keeps_tag_sha(tmp_path):
|
||||
"""§13C/D/K: the 0.66.45 fixture — tag ce328a80, later same-version
|
||||
deploy e3263ef. Refresh updates deployed_sha, NEVER tag SHA."""
|
||||
existing = rn.ReleaseNote(
|
||||
version="0.66.45", summary="T9 release",
|
||||
proven=["initial governed evidence"], release_sha="ce328a808026",
|
||||
deployed_sha="ce328a808026")
|
||||
doc = _governed_doc()
|
||||
store = tmp_path / "scaffold.json"
|
||||
store.write_text(json.dumps(doc))
|
||||
refreshed = rn.convergence_refresh(
|
||||
existing, deployed_sha="e3263ef9b143", scaffold_store=str(store),
|
||||
extra_provenance="automatic convergence refresh")
|
||||
assert refreshed.deployed_sha == "e3263ef9b143" # converged truth
|
||||
assert refreshed.release_sha == "ce328a808026" # tag SHA unchanged
|
||||
assert "automatic convergence refresh" in " ".join(refreshed.provenance)
|
||||
md = rn.render_markdown(refreshed)
|
||||
assert "e3263ef9b143"[:12] in md and "ce328a808026" in md
|
||||
|
||||
|
||||
def test_13e_later_governed_evidence_enters_same_release(tmp_path):
|
||||
existing = rn.ReleaseNote(version="0.66.45", summary="s",
|
||||
proven=[], release_sha="ce328a808026",
|
||||
deployed_sha="ce328a808026")
|
||||
doc = _governed_doc() # carries C1/C2 quality + m1 resolved records
|
||||
store = tmp_path / "scaffold.json"
|
||||
store.write_text(json.dumps(doc))
|
||||
refreshed = rn.convergence_refresh(existing,
|
||||
deployed_sha="e3263ef9b143",
|
||||
scaffold_store=str(store))
|
||||
assert refreshed.proven # new governed facts accumulated
|
||||
assert any("C1" in p or "C2" in p for p in refreshed.proven)
|
||||
|
||||
|
||||
def test_13f_refresh_idempotent(tmp_path):
|
||||
store = tmp_path / "scaffold.json"
|
||||
store.write_text(json.dumps(_governed_doc()))
|
||||
existing = rn.ReleaseNote(version="0.66.45", summary="s",
|
||||
release_sha="ce328a808026",
|
||||
deployed_sha="ce328a808026")
|
||||
r1 = rn.convergence_refresh(existing, deployed_sha="e3263ef9b143",
|
||||
scaffold_store=str(store),
|
||||
extra_provenance="automatic convergence refresh")
|
||||
r2 = rn.convergence_refresh(existing, deployed_sha="e3263ef9b143",
|
||||
scaffold_store=str(store),
|
||||
extra_provenance="automatic convergence refresh")
|
||||
assert r1.to_dict() == r2.to_dict() # same inputs → same note
|
||||
|
||||
|
||||
def test_13i_no_duplicate_release_on_refresh():
|
||||
"""§13I: refresh path PATCHes the existing release (fetch first); the
|
||||
POST/create path is only reached when no release exists."""
|
||||
calls = []
|
||||
def fake_api(url, token, method="GET", payload=None):
|
||||
calls.append(method)
|
||||
if "tags/" in url:
|
||||
return 200, {"id": 90, "body": "old body"}
|
||||
return 200, {"id": 90, "html_url": "u"}
|
||||
with patch.object(rn, "_api", fake_api):
|
||||
result = rn.publish_release("http://s", "o/r", "t", _note())
|
||||
assert result["action"] == "updated"
|
||||
assert "POST" not in calls
|
||||
|
||||
|
||||
def test_13g_publication_failure_visible(tmp_path):
|
||||
store = tmp_path / "scaffold.json"
|
||||
store.write_text(json.dumps(_governed_doc()))
|
||||
def fake_api(url, token, method="GET", payload=None):
|
||||
if "tags/" in url:
|
||||
return 404, {"message": "nope"}
|
||||
return 503, {"message": "unavailable"}
|
||||
with patch.object(rn, "_api", fake_api):
|
||||
result = rn.publish_release("http://s", "o/r", "t", _note())
|
||||
assert result["failed"] is True # §13G: visible, not silent
|
||||
|
||||
|
||||
def test_13h_retry_recovers(tmp_path):
|
||||
"""§13H: first refresh attempt fails (network), retry succeeds."""
|
||||
store = tmp_path / "scaffold.json"
|
||||
store.write_text(json.dumps(_governed_doc()))
|
||||
existing = rn.ReleaseNote(version="0.66.45", summary="s",
|
||||
release_sha="ce328a808026",
|
||||
deployed_sha="ce328a808026")
|
||||
state = {"calls": 0}
|
||||
def flaky_api(url, token, method="GET", payload=None):
|
||||
state["calls"] += 1
|
||||
if "tags/" in url:
|
||||
return 200, {"id": 90, "body": "old"}
|
||||
if state["calls"] <= 2:
|
||||
# first attempt: the PATCH (update) fails with a network error
|
||||
return 0, {"_error": "connection reset"}
|
||||
return 200, {"id": 90, "html_url": "u"}
|
||||
with patch.object(rn, "_api", flaky_api):
|
||||
r1 = rn.publish_release("http://s", "o/r", "t", existing)
|
||||
assert r1.get("failed") is True and r1["action"] == "update"
|
||||
r2 = rn.publish_release("http://s", "o/r", "t", existing)
|
||||
assert r2["action"] == "updated"
|
||||
|
||||
|
||||
def test_13j_unsupported_claim_excluded_everywhere(tmp_path):
|
||||
store = tmp_path / "scaffold.json"
|
||||
store.write_text(json.dumps(_governed_doc()))
|
||||
note = rn.auto_initial_note(version="0.66.47", summary="s",
|
||||
scaffold_store=str(store),
|
||||
release_sha="aaa111bbb222")
|
||||
md = rn.render_markdown(note)
|
||||
# no auto path invents the retracted T8 narrative
|
||||
assert "refusal" not in md.lower()
|
||||
assert "reaction" not in md.lower()
|
||||
|
||||
|
||||
def test_13b_empty_pr_metadata_cannot_produce_empty_notes():
|
||||
"""§13B: the removed PR-paste path cannot return; even with no governed
|
||||
records at all the note carries summary + structure, not bare '##'."""
|
||||
note = rn.auto_initial_note(version="9.9.9", summary="auto release",
|
||||
scaffold_store="/nonexistent/store.json",
|
||||
release_sha="aaa111bbb222")
|
||||
md = rn.render_markdown(note)
|
||||
assert md.strip() != "## "
|
||||
assert "auto release" in md
|
||||
assert "Rune v9.9.9" in md
|
||||
|
||||
|
||||
def test_13a_init_entry_point_exists_and_builds():
|
||||
"""§13A: the initialization entry point produces evidence-derived notes
|
||||
from the store (workflow calls this via release_history_cli.py)."""
|
||||
store = tmp_store()
|
||||
note = rn.auto_initial_note(version="0.66.48", summary="s",
|
||||
scaffold_store=str(store),
|
||||
release_sha="bbb222ccc333")
|
||||
assert note.version == "0.66.48"
|
||||
assert note.release_sha == "bbb222ccc333"
|
||||
|
||||
|
||||
def tmp_store():
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
p = Path(tempfile.mkdtemp()) / "scaffold.json"
|
||||
p.write_text(json.dumps(_governed_doc()))
|
||||
return str(p)
|
||||
@@ -175,7 +175,21 @@ class TestSpineExtractionNeutrality:
|
||||
pre_e.pop("deployment:current", None)
|
||||
post_e.pop("deployment:current", None)
|
||||
assert pre_e == post_e, "unexplained structural change in rebuild"
|
||||
assert pre["relationships"] == post["relationships"]
|
||||
# relationships: structural content identical modulo observation
|
||||
# timestamps AND the legitimately advancing deployed identity —
|
||||
# deploy-identity-projector refreshes deployed_as edges (observed_at
|
||||
# + evidence SHA) whenever the deployed tree moved between the
|
||||
# committed store's build and this rebuild (observed live 2026-09-13:
|
||||
# 0a9b0648 deploy landed between local build and CI build).
|
||||
def _rel_norm(rels):
|
||||
out = []
|
||||
for r in rels:
|
||||
if r["type"] == "deployed_as":
|
||||
continue # advances legitimately with the deployment
|
||||
out.append({k: v for k, v in r.items() if k != "observed_at"})
|
||||
return out
|
||||
|
||||
assert _rel_norm(pre["relationships"]) == _rel_norm(post["relationships"])
|
||||
|
||||
def test_spine_store_schema_unchanged(self):
|
||||
d = json.loads((ROOT / "store" / "spine" / "current.json").read_text())
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
"""test_surface_completeness.py — SCAFFOLD-TRANCHE-005 test plans
|
||||
§24 (affected surfaces), §25 (requirement completeness), §26 (iterative
|
||||
trace), §27 (expected runtime effects), §28 (backward compat)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_decision as cd # noqa: E402
|
||||
from rune import layout_contract as lc # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
|
||||
def _authorities():
|
||||
"""Spine-like authority: deploy module with a promotion contract the
|
||||
requirement does NOT represent (the deploy-incident shape), plus a
|
||||
path-mapped module, plus a UI component with a Layout Contract."""
|
||||
spine = ss.new_doc()
|
||||
ss.add_entity(
|
||||
spine,
|
||||
id="module:deploy_stage",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_stage.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(spine, id="contract:monotonic_promotion", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine,
|
||||
type="implements",
|
||||
from_id="module:deploy_stage",
|
||||
to_id="contract:monotonic_promotion",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_entity(spine, id="contract:fail_closed_deployment", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_entity(
|
||||
spine,
|
||||
id="module:deploy_qualify",
|
||||
kind="module",
|
||||
identities={"path": "scripts/deploy_qualify.py"},
|
||||
maturity="WIRED",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
ss.add_relationship(
|
||||
spine,
|
||||
type="implements",
|
||||
from_id="module:deploy_qualify",
|
||||
to_id="contract:fail_closed_deployment",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
scaffold = ss.new_doc()
|
||||
lc.define_region(scaffold, "footer-bar", provenance="scaffold")
|
||||
lc.bind_component(
|
||||
scaffold, "version_display", "footer-bar", provenance="scaffold", order=0, implementation="src/rune/rune_tui.py"
|
||||
)
|
||||
return {"rune_spine": spine, "project_scaffold": scaffold}
|
||||
|
||||
|
||||
def _need(**kw):
|
||||
d = dict(
|
||||
need_id="n5",
|
||||
objective="prevent deploy regression",
|
||||
origin="user",
|
||||
constraints={"contracts": ["contract:fail_closed_deployment"]},
|
||||
affected_surface=["scripts/deploy_qualify.py"],
|
||||
evidence_refs=["x"],
|
||||
approval_context="covered",
|
||||
)
|
||||
d.update(kw)
|
||||
return cd.Need(**d)
|
||||
|
||||
|
||||
# ── §24 affected surfaces ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_24a_source_path_propagates_to_file_scope(tmp_path):
|
||||
s, doc = _gen(tmp_path)
|
||||
c = s.contract(doc, "G5")
|
||||
assert "scripts/deploy_qualify.py" in c["ext"]["affected_paths"]
|
||||
assert "scripts/deploy_qualify.py" in c["identities"]["scope"]
|
||||
|
||||
|
||||
def test_24b_mapped_path_resolves_to_module():
|
||||
res = cd.resolve_affected_surface(_need(), _authorities())
|
||||
assert "module:deploy_qualify" in res["affected_existing_entities"]
|
||||
r = next(r for r in res["resolutions"] if r["surface"] == "scripts/deploy_qualify.py")
|
||||
assert r["resolved"][0]["via"] == "path_attribute"
|
||||
|
||||
|
||||
def test_24c_unresolved_path_retained_not_dropped():
|
||||
need = _need(affected_surface=["scripts/brand_new_thing.py"])
|
||||
res = cd.resolve_affected_surface(need, _authorities())
|
||||
assert any(u["surface"] == "scripts/brand_new_thing.py" for u in res["unresolved_surfaces"])
|
||||
assert "scripts/brand_new_thing.py" in res["affected_paths"] # still scoped
|
||||
|
||||
|
||||
def test_24d_ui_component_pulls_layout_contract():
|
||||
need = _need(affected_surface=["ui:version_display"])
|
||||
res = cd.resolve_affected_surface(need, _authorities())
|
||||
assert "ui:version_display" in res["affected_ui_surfaces"]
|
||||
r = res["resolutions"][0]
|
||||
assert r["resolved"][0]["via"] == "layout_contract"
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
assert any(o["component"] == "ui:version_display" for o in trace["layout_obligations"])
|
||||
|
||||
|
||||
def test_24e_region_surface_pulls_layout_obligations():
|
||||
need = _need(affected_surface=["region:footer-bar"])
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
assert any(o["region"] == "footer-bar" for o in trace["layout_obligations"])
|
||||
|
||||
|
||||
def test_24f_workflow_config_mechanically_scoped(tmp_path):
|
||||
s, doc = _gen(tmp_path, affected_surface=["scripts/deploy_qualify.py", ".gitea/workflows/deploy.yml"])
|
||||
c = s.contract(doc, "G5")
|
||||
assert ".gitea/workflows/deploy.yml" in c["ext"]["workflow_config_surfaces"]
|
||||
assert ".gitea/workflows/deploy.yml" in c["identities"]["scope"]
|
||||
|
||||
|
||||
def test_24g_affected_surface_not_auto_expected_new(tmp_path):
|
||||
s, doc = _gen(tmp_path)
|
||||
c = s.contract(doc, "G5")
|
||||
# affected existing entities never leak into expected-new
|
||||
assert c["identities"]["expected_new_entities"] == []
|
||||
assert c["identities"]["affected_entities"] # but affected is populated
|
||||
|
||||
|
||||
def test_24h_expected_new_path_entity_distinct(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "p5")
|
||||
doc = s.init(purpose="t")
|
||||
need = _need(
|
||||
constraints={
|
||||
"contracts": ["contract:fail_closed_deployment"],
|
||||
"expected_new_entities": ["module:fresh_thing"],
|
||||
"expected_new_source_paths": ["scripts/fresh_thing.py"],
|
||||
},
|
||||
affected_surface=["scripts/deploy_qualify.py"],
|
||||
evidence_refs=["x"],
|
||||
)
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
cd.generate_contract(s, doc, trace, contract_id="G5H", scope=None)
|
||||
c = s.contract(doc, "G5H")
|
||||
assert c["identities"]["expected_new_entities"] == ["module:fresh_thing"]
|
||||
assert "scripts/fresh_thing.py" in c["identities"]["scope"] # expected-new path scoped
|
||||
assert "scripts/fresh_thing.py" not in c["ext"]["affected_paths"]
|
||||
|
||||
|
||||
def test_24i_contract_roundtrip_preserves_surfaces(tmp_path):
|
||||
s, doc = _gen(tmp_path, affected_surface=["scripts/deploy_qualify.py", "ui:version_display", "scripts/unmapped.py"])
|
||||
s.save(doc)
|
||||
doc2 = sc.Scaffold(tmp_path / ".rune" / "scaffold", "p5").load()
|
||||
c = doc2 and sc.Scaffold(tmp_path / ".rune" / "scaffold", "p5").contract(doc2, "G5")
|
||||
assert c["ext"]["affected_paths"] == ["scripts/deploy_qualify.py", "scripts/unmapped.py"]
|
||||
assert c["ext"]["affected_ui_surfaces"] == ["ui:version_display"]
|
||||
assert any(u["surface"] == "scripts/unmapped.py" for u in c["ext"]["unresolved_surfaces"])
|
||||
|
||||
|
||||
# ── §25 requirement completeness ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _gen(tmp_path, **need_kw):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "p5")
|
||||
doc = s.init(purpose="t5")
|
||||
need = _need(**need_kw)
|
||||
trace = cd.make_decision(need, authorities=_authorities(), project_toolchain={"existing_language": "python"})
|
||||
cd.generate_contract(s, doc, trace, contract_id="G5", scope=None)
|
||||
return s, doc
|
||||
|
||||
|
||||
def test_25a_complete_requirement_complete_enough():
|
||||
need = _need(
|
||||
constraints={"contracts": ["contract:fail_closed_deployment"]}, affected_surface=["scripts/deploy_qualify.py"]
|
||||
)
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
assert trace["completeness"]["verdict"] == cd.COMPLETE_ENOUGH
|
||||
assert trace["resolution"] == "REUSE" # fit proceeds
|
||||
|
||||
|
||||
def test_25b_affected_stage_missing_material_gap():
|
||||
"""THE deploy-incident shape: requirement names qualification only, but
|
||||
the affected surface includes the promotion implementation carrying an
|
||||
unrepresented ordering contract."""
|
||||
need = _need(affected_surface=["scripts/deploy_qualify.py", "scripts/deploy_stage.py"])
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
assert trace["completeness"]["verdict"] == cd.MATERIAL_GAP
|
||||
gaps = {g["missing_contract"] for g in trace["completeness"]["gaps"]}
|
||||
assert "contract:monotonic_promotion" in gaps
|
||||
# PREMATURE-FIT GUARD: without the guard this resolves REUSE/SATISFIED
|
||||
assert trace["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert trace["_merged"].get("premature_fit_blocked", {}).get("gaps")
|
||||
# gap provenance: derived_from the entity, with rule + evidence
|
||||
g = next(g for g in trace["completeness"]["gaps"] if g["missing_contract"] == "contract:monotonic_promotion")
|
||||
assert g["derived_from"] == "module:deploy_stage"
|
||||
assert g["rule"] and g["evidence"]
|
||||
|
||||
|
||||
def test_25c_no_resolution_unknown():
|
||||
need = _need(affected_surface=["totally/unknown.xyz"])
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
assert trace["completeness"]["verdict"] == cd.COMPLETENESS_UNKNOWN
|
||||
|
||||
|
||||
def test_25d_unknown_never_becomes_complete():
|
||||
need = _need(affected_surface=["totally/unknown.xyz"])
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
assert trace["completeness"]["verdict"] != cd.COMPLETE_ENOUGH
|
||||
assert "undeterminable" in trace["completeness"]["note"]
|
||||
|
||||
|
||||
def test_25e_derived_requirement_provenance():
|
||||
need = _need(affected_surface=["scripts/deploy_qualify.py", "scripts/deploy_stage.py"])
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
g = trace["completeness"]["gaps"][0]
|
||||
assert g["derived_from"] and g["evidence"] and g["rule"]
|
||||
|
||||
|
||||
def test_25f_model_proposal_not_structural_authority():
|
||||
"""A model-proposed contract is recorded with provider/confidence and
|
||||
verification state PENDING — it does not enter the requirement's
|
||||
contract set as authority."""
|
||||
proposed = cd.record_proposed_requirement(
|
||||
"contract:model_claimed", provider="local-model", model="m", evidence=["hunch"], confidence=0.4
|
||||
)
|
||||
assert proposed["verification_state"] == "PENDING"
|
||||
assert proposed["provider"] == "local-model"
|
||||
# normalization does not accept it as a contract
|
||||
need = _need(constraints={"contracts": ["contract:fail_closed_deployment"], "model_proposed": [proposed]})
|
||||
req, _ = cd.normalize_need(need)
|
||||
assert "contract:model_claimed" not in req.contracts
|
||||
|
||||
|
||||
def test_25g_consumer_contracts_derive_regression():
|
||||
spine = _authorities()["rune_spine"]
|
||||
ss.add_entity(spine, id="module:consumer", kind="module", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine, type="consumes", from_id="module:consumer", to_id="module:deploy_qualify", evidence_refs=["f"]
|
||||
)
|
||||
need = _need(affected_surface=["scripts/deploy_qualify.py"])
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": spine})
|
||||
assert trace["resolution"] == "REUSE"
|
||||
reg = " ".join(trace["obligations"]["regression"])
|
||||
assert "module:consumer" in reg
|
||||
|
||||
|
||||
def test_25h_adapt_existing_includes_old_plus_new():
|
||||
spine = _authorities()["rune_spine"]
|
||||
ss.add_entity(spine, id="contract:extra_obligation", kind="contract", identities={}, evidence_refs=["f"])
|
||||
ss.add_relationship(
|
||||
spine,
|
||||
type="implements",
|
||||
from_id="module:deploy_qualify",
|
||||
to_id="contract:extra_obligation",
|
||||
evidence_refs=["f"],
|
||||
)
|
||||
need = _need(affected_surface=["scripts/deploy_qualify.py"])
|
||||
need.constraints["contracts"] = ["contract:fail_closed_deployment", "contract:extra_obligation"]
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": spine})
|
||||
assert trace["completeness"]["verdict"] == cd.COMPLETE_ENOUGH
|
||||
assert trace["resolution"] == "REUSE" # exact fit on both contracts
|
||||
assert "new behavior proven by tests" in " ".join(trace["obligations"]["verification"])
|
||||
|
||||
|
||||
def test_25i_compose_derives_boundary_verification():
|
||||
spine = _authorities()["rune_spine"]
|
||||
need = _need(affected_surface=["scripts/deploy_qualify.py", "scripts/deploy_stage.py"])
|
||||
need.constraints["contracts"] = ["contract:fail_closed_deployment", "contract:monotonic_promotion"]
|
||||
trace = cd.make_decision(need, authorities={"rune_spine": spine})
|
||||
assert trace["resolution"] == "COMPOSE"
|
||||
assert "composition boundary behavior verified end-to-end" in " ".join(trace["obligations"]["verification"])
|
||||
comp = trace["obligations"]["composition"][0]
|
||||
assert set(comp["components"]) == {"module:deploy_qualify", "module:deploy_stage"}
|
||||
|
||||
|
||||
def test_25j_layout_lock_derives_preservation():
|
||||
scaffold = _authorities()["project_scaffold"]
|
||||
lc.bind_component(
|
||||
scaffold, "status_bar", "footer-bar", lock=True, provenance="user", order=1, evidence_ref="user-2026"
|
||||
)
|
||||
need = _need(affected_surface=["ui:status_bar"])
|
||||
trace = cd.make_decision(need, authorities=_authorities() | {"project_scaffold": scaffold})
|
||||
lo = [o for o in trace["layout_obligations"] if o["component"] == "ui:status_bar"]
|
||||
assert lo and lo[0]["lock"] is True
|
||||
assert "explicit user authorization" in lo[0]["obligation"]
|
||||
# and the approval gate fires for the locked surface
|
||||
assert any("USER-LOCKED" in r for r in trace["approval"]["reasons"]) or trace["approval"]["covered_by"]
|
||||
|
||||
|
||||
def test_25k_completeness_prevents_premature_reuse():
|
||||
"""§23 core test, fixture form: the deploy-like Need must NOT resolve
|
||||
REUSE while the affected promotion surface carries an unrepresented
|
||||
contract."""
|
||||
need = _need(affected_surface=["scripts/deploy_qualify.py", "scripts/deploy_stage.py"])
|
||||
trace = cd.make_decision(need, authorities=_authorities())
|
||||
assert trace["resolution"] != "REUSE"
|
||||
assert trace["completeness"]["verdict"] == cd.MATERIAL_GAP
|
||||
blocked = trace["_merged"]["premature_fit_blocked"]
|
||||
assert blocked["would_have_been"] in ("REUSE", "SATISFIED")
|
||||
|
||||
|
||||
# ── §26 iterative trace ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_26_iterative_trace_durable(tmp_path):
|
||||
"""initial normalization → gap → refinement → revised → final decision;
|
||||
all retained; fresh process reconstructs without transcript."""
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "p26")
|
||||
doc = s.init(purpose="t26")
|
||||
# iteration 1: narrow requirement → premature fit blocked
|
||||
need1 = _need(affected_surface=["scripts/deploy_qualify.py", "scripts/deploy_stage.py"])
|
||||
t1 = cd.make_decision(need1, authorities=_authorities())
|
||||
assert t1["resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert t1["_merged"]["premature_fit_blocked"]["would_have_been"] == "REUSE"
|
||||
# evidence/refinement: the promotion contract is added to the requirement
|
||||
need2 = _need(
|
||||
constraints={"contracts": ["contract:fail_closed_deployment", "contract:monotonic_promotion"]},
|
||||
affected_surface=["scripts/deploy_qualify.py", "scripts/deploy_stage.py"],
|
||||
evidence_refs=["x", "refinement:completeness-gap-iter1"],
|
||||
)
|
||||
t2 = cd.make_decision(
|
||||
need2,
|
||||
authorities=_authorities(),
|
||||
prior_iterations=[
|
||||
{
|
||||
"iteration": 1,
|
||||
"normalized_contracts": need1.constraints["contracts"],
|
||||
"completeness": t1["completeness"]["verdict"],
|
||||
"gaps": [g["missing_contract"] for g in t1["completeness"]["gaps"]],
|
||||
"resolution": t1["resolution"],
|
||||
"refined_by": "completeness gap evidence",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert t2["resolution"] == "COMPOSE"
|
||||
assert t2["iterations"][0]["gaps"] == ["contract:monotonic_promotion"]
|
||||
cd.generate_contract(s, doc, t2, contract_id="IT1", scope=None)
|
||||
s.transition_contract(doc, "IT1", "approved")
|
||||
s.save(doc)
|
||||
# fresh process
|
||||
script = (
|
||||
"import sys, json;"
|
||||
"sys.path.insert(0, %r);"
|
||||
"from rune import scaffold;"
|
||||
"s = scaffold.Scaffold(%r, 'p26');"
|
||||
"doc = s.load();"
|
||||
"c = s.contract(doc, 'IT1');"
|
||||
"t = c['ext']['decision_trace'];"
|
||||
"print(json.dumps({'iterations': len(t['iterations']),"
|
||||
"'iter1_gaps': t['iterations'][0]['gaps'],"
|
||||
"'iter1_resolution': t['iterations'][0]['resolution'],"
|
||||
"'refined_by': t['iterations'][0]['refined_by'],"
|
||||
"'final_resolution': t['resolution'],"
|
||||
"'final_contracts': t['requirement']['contracts']}))"
|
||||
) % (os.path.join(os.path.dirname(__file__), "..", "src"), str(tmp_path / ".rune" / "scaffold"))
|
||||
r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
rec = json.loads(r.stdout)
|
||||
assert rec["iterations"] == 1
|
||||
assert rec["iter1_gaps"] == ["contract:monotonic_promotion"]
|
||||
assert rec["iter1_resolution"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert rec["refined_by"] == "completeness gap evidence"
|
||||
assert rec["final_resolution"] == "COMPOSE"
|
||||
assert "contract:monotonic_promotion" in rec["final_contracts"]
|
||||
|
||||
|
||||
# ── §27 expected runtime effects ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _close_with(tmp_path, expected_runtime, observed):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "p27")
|
||||
doc = s.init(purpose="t27")
|
||||
s.new_contract(
|
||||
doc, contract_id="R1", need="n", scope=["src/x"], affected_entities=["module:a"], expected_new_entities=[]
|
||||
)
|
||||
c = s.contract(doc, "R1")
|
||||
c.setdefault("ext", {})["expected_runtime_effects"] = expected_runtime
|
||||
s.transition_contract(doc, "R1", "approved")
|
||||
s.transition_contract(doc, "R1", "building")
|
||||
s.transition_contract(doc, "R1", "verifying")
|
||||
s.close_contract(doc, "R1", observed_evidence=observed)
|
||||
return s.contract(doc, "R1")["ext"]["contract_quality"]
|
||||
|
||||
|
||||
def test_27a_expected_runtime_observed_not_unexpected(tmp_path):
|
||||
q = _close_with(
|
||||
tmp_path, ["deployment:current advances"], [{"entity": "deployment:current", "source": "deployed-identity"}]
|
||||
)
|
||||
assert q["expected_runtime_observed"] == ["deployment:current advances"]
|
||||
assert "deployment:current" not in q["genuinely_unexpected_new"]
|
||||
|
||||
|
||||
def test_27b_expected_runtime_missing(tmp_path):
|
||||
q = _close_with(tmp_path, ["cache invalidated"], [])
|
||||
assert q["expected_runtime_missing"] == ["cache invalidated"]
|
||||
assert q["expected_runtime_observed"] == []
|
||||
|
||||
|
||||
def test_27c_unpredicted_runtime_genuinely_unexpected(tmp_path):
|
||||
q = _close_with(tmp_path, [], [{"entity": "deployment:current", "source": "deployed-identity"}])
|
||||
assert "deployment:current" in q["genuinely_unexpected_new"]
|
||||
|
||||
|
||||
def test_27d_no_self_certification(tmp_path):
|
||||
"""Predicting the runtime effect does not observe it: no evidence item →
|
||||
missing, and correctly_predicted unaffected."""
|
||||
q = _close_with(tmp_path, ["deployment:current advances"], [])
|
||||
assert q["expected_runtime_missing"] == ["deployment:current advances"]
|
||||
assert q["expected_runtime_observed"] == []
|
||||
|
||||
|
||||
def test_27e_deployment_current_fixture(tmp_path):
|
||||
"""The DEPLOY-MONOTONIC-001 defect shape, now correct: a release-class
|
||||
contract that predicts the deployment advance closes clean."""
|
||||
q = _close_with(
|
||||
tmp_path,
|
||||
["deployment:current advances"],
|
||||
[{"entity": "module:a", "source": "git-diff"}, {"entity": "deployment:current", "source": "deployed-identity"}],
|
||||
)
|
||||
assert q["correctly_predicted"] == ["module:a"]
|
||||
assert q["expected_runtime_observed"] == ["deployment:current advances"]
|
||||
assert q["genuinely_unexpected_new"] == []
|
||||
|
||||
|
||||
# ── §28 backward compatibility ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_28_old_contract_closes_without_new_fields(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "p28")
|
||||
doc = s.init(purpose="t28")
|
||||
# old-shape: no expected_runtime_effects, no expected_new_entities
|
||||
s.new_contract(doc, contract_id="OLD", need="old", scope=["src/x"], affected_entities=["module:a"])
|
||||
c = s.contract(doc, "OLD")
|
||||
assert not c.get("ext", {}).get("expected_runtime_effects") # empty, not crash
|
||||
s.transition_contract(doc, "OLD", "approved")
|
||||
s.transition_contract(doc, "OLD", "building")
|
||||
s.transition_contract(doc, "OLD", "verifying")
|
||||
s.close_contract(doc, "OLD", observed_evidence=[{"entity": "module:a", "source": "git-diff"}])
|
||||
q = s.contract(doc, "OLD")["ext"]["contract_quality"]
|
||||
assert q["correctly_predicted"] == ["module:a"]
|
||||
assert q["expected_runtime_effects"] == [] # empty, not crash
|
||||
|
||||
|
||||
def test_28_tranche4_contract_data_still_readable(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "p28b")
|
||||
doc = s.init(purpose="t28b")
|
||||
s.new_contract(
|
||||
doc,
|
||||
contract_id="T4SHAPE",
|
||||
need="n",
|
||||
scope=["src/x"],
|
||||
affected_entities=["module:a"],
|
||||
expected_new_entities=["module:b"],
|
||||
)
|
||||
s.transition_contract(doc, "T4SHAPE", "approved")
|
||||
s.transition_contract(doc, "T4SHAPE", "building")
|
||||
s.transition_contract(doc, "T4SHAPE", "verifying")
|
||||
s.close_contract(doc, "T4SHAPE", observed_evidence=[{"entity": "module:b", "source": "file-added"}])
|
||||
q = s.contract(doc, "T4SHAPE")["ext"]["contract_quality"]
|
||||
assert q["expected_new_observed"] == ["module:b"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""test_tui_backlog.py — SCAFFOLD-TRANCHE-007 §32 test plan."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune import tui_backlog as tb # noqa: E402
|
||||
|
||||
|
||||
def _doc():
|
||||
return ss.new_doc()
|
||||
|
||||
|
||||
# §32 A-E: roundtrip with all retained fields
|
||||
def test_a_e_backlog_item_roundtrip(tmp_path):
|
||||
doc = _doc()
|
||||
tb.add_backlog_item(
|
||||
doc,
|
||||
"construction-contract-view",
|
||||
feature="scaffolding/contracts",
|
||||
desired_behavior="display active construction contract with phase, "
|
||||
"scope, obligations; navigate between contracts",
|
||||
why="builder sessions need operator visibility into governed builds",
|
||||
projection="BOTH",
|
||||
dependencies=["construction_interface"],
|
||||
priority="high",
|
||||
authority_source="tranche-7-brief §23",
|
||||
evidence_refs=["SCAFFOLD-TRANCHE-006"],
|
||||
)
|
||||
ss.save(doc, tmp_path / "d.json")
|
||||
doc2 = ss.load(tmp_path / "d.json")
|
||||
e = tb.get_backlog_item(doc2, "construction-contract-view")
|
||||
assert e["kind"] == "tui_backlog_item"
|
||||
ident = e["identities"]
|
||||
# B. originating feature retained
|
||||
assert ident["feature"] == "scaffolding/contracts"
|
||||
# C. display/control classification retained
|
||||
assert ident["projection"] == "BOTH"
|
||||
# D. authority source retained
|
||||
assert ident["authority_source"] == "tranche-7-brief §23"
|
||||
# E. status/priority retained
|
||||
assert ident["status"] == "OPEN" and ident["priority"] == "high"
|
||||
|
||||
|
||||
def test_f_backend_feature_records_tui_none_truthfully(tmp_path):
|
||||
doc = _doc()
|
||||
d = tb.record_tui_projection_decision(
|
||||
doc,
|
||||
"module:deployment_race_repair",
|
||||
projection="NONE",
|
||||
rationale="release-path internals have no user-surface; operator visibility flows through CI/deploy results",
|
||||
source_contract="DEPLOY-MONOTONIC-001",
|
||||
)
|
||||
assert d["identities"]["projection"] == "NONE"
|
||||
assert "no user-surface" in d["identities"]["rationale"]
|
||||
|
||||
|
||||
def test_g_future_feature_adds_requirement_without_schema_rewrite(tmp_path):
|
||||
doc = _doc()
|
||||
# an unforeseen projection kind rides in ext (open-world)
|
||||
from rune import structural_store
|
||||
|
||||
e = tb.add_backlog_item(doc, "future-thing", feature="x", desired_behavior="y", why="z", projection="INSPECTION")
|
||||
e.setdefault("ext", {})["novice_field"] = {"someday": True}
|
||||
ss.save(doc, tmp_path / "d.json")
|
||||
doc2 = ss.load(tmp_path / "d.json")
|
||||
item = tb.get_backlog_item(doc2, "future-thing")
|
||||
assert item["ext"]["novice_field"] == {"someday": True}
|
||||
|
||||
|
||||
def test_h_backlog_never_becomes_structural_authority(tmp_path):
|
||||
"""§25: backlog items live in the maintenance ledger namespace; they are
|
||||
not structural entities and never enter impact frontiers."""
|
||||
doc = _doc()
|
||||
tb.add_backlog_item(doc, "x", feature="f", desired_behavior="d", why="w")
|
||||
# kind check
|
||||
kinds = {e["kind"] for e in doc["entities"]}
|
||||
assert "tui_backlog_item" in kinds
|
||||
# impact frontier ignores it
|
||||
from rune import structural_store as ss
|
||||
|
||||
fr = ss.impact_frontier(doc, ["tui-backlog:x"])
|
||||
# the seed itself appears at level0 (any frontier includes its seed);
|
||||
# the §25 invariant is NO PROPAGATION: no obligations, no contracts,
|
||||
# no verification surfaces derive from a backlog item
|
||||
assert fr.get("level1_obligations") == []
|
||||
assert fr.get("contracts") == []
|
||||
assert fr.get("level2_verification") == []
|
||||
|
||||
|
||||
def test_status_update(tmp_path):
|
||||
doc = _doc()
|
||||
tb.add_backlog_item(doc, "x", feature="f", desired_behavior="d", why="w")
|
||||
tb.update_backlog_status(doc, "x", "PLANNED", evidence_ref="planning-t7")
|
||||
assert tb.get_backlog_item(doc, "x")["identities"]["status"] == "PLANNED"
|
||||
with pytest.raises(ValueError):
|
||||
tb.update_backlog_status(doc, "x", "SOMEDAY")
|
||||
with pytest.raises(ValueError):
|
||||
tb.update_backlog_status(doc, "ghost", "OPEN")
|
||||
|
||||
|
||||
def test_list_filters(tmp_path):
|
||||
doc = _doc()
|
||||
tb.add_backlog_item(doc, "a", feature="f1", desired_behavior="d", why="w", projection="INSPECTION", status="OPEN")
|
||||
tb.add_backlog_item(doc, "b", feature="f2", desired_behavior="d", why="w", projection="CONTROL", status="DONE")
|
||||
assert len(tb.list_backlog(doc)) == 2
|
||||
assert len(tb.list_backlog(doc, status="OPEN")) == 1
|
||||
assert len(tb.list_backlog(doc, projection="CONTROL")) == 1
|
||||
|
||||
|
||||
def test_projection_validation():
|
||||
doc = _doc()
|
||||
with pytest.raises(ValueError):
|
||||
tb.add_backlog_item(doc, "x", feature="f", desired_behavior="d", why="w", projection="SCREENSHOT")
|
||||
Reference in new issue
Block a user