Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,42 @@ versions per [PEP 440](https://peps.python.org/pep-0440/) /

_Post-1.0.0 work lands here; legis versions independently from the Weft 1.0 launch on._

## [1.1.0] — 2026-06-19

Three defects surfaced by a `lacuna` dogfooding pass, confirmed (investigation +
adversarial verification) against the shipped 1.0.0 surface and fixed test-first.

### ⚠️ BREAKING (for pinned fingerprints) — version-stable policy-boundary fingerprint

- **`@policy_boundary` `test_fingerprint` is now interpreter-stable (legis-13b4e97bf4).**
The fingerprint canonicalization hashed raw `ast.dump` output, whose text is
Python-version-dependent (3.13 omits default-empty AST fields that 3.12 renders), so a
fingerprint pinned under one interpreter reported a spurious
`POLICY_BOUNDARY_TEST_FINGERPRINT_MISMATCH` under another. `decorator.get_normalized_ast_str`
now serializes via `_stable_ast_repr`, which emits **every** field of every node in a
fixed order — version-stable by construction (pinned by a cross-interpreter 3.12↔3.13
test). **This changes the fingerprint value for all sources.** Consumers with pinned
`test_fingerprint`s (e.g. lacuna's specimen) must regenerate them once against 1.1.0;
after that they no longer drift across Python minors.

### Fixed

- **Posture reads degrade instead of crashing on an unprovisioned ledger (legis-5fd3b257c3).**
Production opens the posture ledger `initialize=False`, so a pre-posture / empty (no
`audit_log` table) DB made `posture_get` and `policy_list` raise
`OperationalError: no such table: audit_log`, surfaced to the agent as a non-recoverable
`INTERNAL_ERROR` leaking the SQL string — the intended fail-closed `structured` degrade
was never delivered. `AuditStore` reads (`get_latest_sequence_and_hash`, `read_all`,
`read_by_seq`) now treat an absent table as an empty store (the same state as a missing
file), so every posture read fails closed to `structured` rather than crashing.
- **`install --posture --insecure-key-in-env` adopts the operator key instead of dropping it
(legis-1844bf8ac9).** `install_posture` unconditionally minted a fresh key and the `env`
custody sink was a no-op, so an operator-supplied `LEGIS_OPERATOR_KEY` was ignored: GENESIS
was stamped with a throwaway key the later `EnvSigner` never held, and every `posture set`
refused with `fingerprint_mismatch` (the floor was a dead read-only affordance). The `env`
backend now adopts and validates `LEGIS_OPERATOR_KEY` (64 hex) as the epoch key, and fails
loud (no dead GENESIS) when it is absent or malformed.

## [1.0.0] — 2026-06-13

This is the gold release — the legis unit of the coordinated **Weft 1.0** launch. It
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "legis"
version = "1.0.0"
version = "1.1.0"
description = "Legis — the git/CI + governance layer of the Weft suite"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/legis/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Legis — the git/CI + governance layer of the Weft suite."""

__version__ = "1.0.0"
__version__ = "1.1.0"
6 changes: 4 additions & 2 deletions src/legis/canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
canonicalization choke point, that upgrade stays a one-file change. The
companion Q-L5 fingerprint
reconciliation (decorator.py / boundary_scan.py) is independent and is done —
those fingerprints are Python ``ast.dump`` output, not cross-language JSON, so
RFC-8785 does not apply to them.
those fingerprints are a content hash of a version-stable AST serialization
(``decorator._stable_ast_repr``, which superseded the interpreter-fragile
``ast.dump`` per legis-13b4e97bf4), not cross-language JSON, so RFC-8785 does not
apply to them.
"""

from __future__ import annotations
Expand Down
46 changes: 42 additions & 4 deletions src/legis/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1243,9 +1243,12 @@ def install_posture(
or a ``KEY_RESET`` tail) -> no mint, no append; return the existing
epoch fingerprint. This mirrors ``PostureLedger.genesis``'s own guard so
install never mints a throwaway key on a second pass.
4. else: ``mint_key()`` -> hand to the backend via ``key_sink`` -> compute
the fingerprint -> ``ledger.genesis(key_fingerprint=fp, ...)``. The key
bytes reach ONLY the sink; the ledger stores the fingerprint alone.
4. else: for the ``env`` backend, ADOPT the operator-supplied key from
``LEGIS_OPERATOR_KEY`` (validated, fail-loud if absent/malformed) so the
later ``EnvSigner`` matches the epoch; for every other backend
``mint_key()``. Then hand to the backend via ``key_sink`` -> compute the
fingerprint -> ``ledger.genesis(key_fingerprint=fp, ...)``. The key bytes
reach ONLY the sink; the ledger stores the fingerprint alone.
"""
from legis.clock import SystemClock
from legis.posture import (
Expand All @@ -1263,7 +1266,14 @@ def install_posture(
if ledger.store.get_latest_sequence_and_hash()[0] != 0:
return ledger.current_epoch_fingerprint()

key_hex = mint_key()
# The env escape hatch SIGNS with LEGIS_OPERATOR_KEY (EnvSigner), so GENESIS
# must be stamped with THAT key's fingerprint — minting a throwaway here would
# leave the floor read-only (the env signer could never match the epoch).
# Adopt + validate it; every other backend mints a fresh key into custody.
if backend == "env":
key_hex = _adopt_env_operator_key()
else:
key_hex = mint_key()
sink = key_sink if key_sink is not None else _default_key_sink
# Hand the key to custody BEFORE writing GENESIS: if custody fails we have
# written no fingerprint we cannot later sign against (fail-closed).
Expand All @@ -1275,6 +1285,34 @@ def install_posture(
return fp


def _adopt_env_operator_key() -> str:
"""Read + validate the operator key from ``LEGIS_OPERATOR_KEY`` (env backend).

The ``--insecure-key-in-env`` path must adopt the operator-supplied key as
the epoch key so the later :class:`~legis.posture.EnvSigner` (which reads
``LEGIS_OPERATOR_KEY`` at sign time) matches the GENESIS epoch. A missing or
malformed key fails LOUD rather than minting a throwaway the signer can never
match — which would leave the floor read-only and ``LEGIS_OPERATOR_KEY`` a
dead affordance (dogfood: legis-1844bf8ac9).
"""
key_hex = os.environ.get("LEGIS_OPERATOR_KEY")
if not key_hex:
raise OperatorKeyCustodyError(
"the env operator-key backend (--insecure-key-in-env) requires the "
"operator key in LEGIS_OPERATOR_KEY; refusing to mint a throwaway key "
"the env signer could never match (the floor would be read-only)"
)
try:
if len(bytes.fromhex(key_hex)) != 32:
raise ValueError
except ValueError:
raise OperatorKeyCustodyError(
"LEGIS_OPERATOR_KEY must be 64 hex chars (a 32-byte key, e.g. as "
"minted by `legis`); refusing to genesis an unusable operator epoch"
) from None
return key_hex


def _default_key_sink(key_hex: str, backend: str) -> None:
"""Default custody hand-off for the minted operator key.

Expand Down
29 changes: 28 additions & 1 deletion src/legis/policy/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,31 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
return decorator


def _stable_ast_repr(node: Any) -> str:
"""A Python-version-stable serialization of an AST node.

``ast.dump`` is NOT version-stable: its 3.13 default (``show_empty=False``)
omits default-empty fields that 3.12 renders, so the same source hashes
differently across interpreters (dogfood: legis-13b4e97bf4). This walks
``node._fields`` explicitly and emits EVERY field in its declared order, so an
empty ``arguments`` node renders identically on every interpreter. Node
attributes (``lineno`` / ``col_offset``) are excluded — matching
``ast.dump``'s default and keeping the fingerprint about structure, not
source formatting.
"""
import ast

if isinstance(node, ast.AST):
fields = ", ".join(
f"{name}={_stable_ast_repr(getattr(node, name, None))}"
for name in node._fields
)
Comment on lines +119 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize AST fields independently of runtime version

Because this walks the current interpreter's node._fields, fields added in newer Python minors still change the serialized text. I checked a source valid on the supported floor, def test_generic[T](): ...: Python 3.12's ast.TypeVar._fields is ('name', 'bound'), while 3.13 adds default_value, so this serializer emits default_value=None only on 3.13 and the fingerprints differ. Any pinned boundary evidence test using PEP-695 type parameters will still report drift after a 3.12→3.13 upgrade.

Useful? React with 👍 / 👎.

return f"{type(node).__name__}({fields})"
if isinstance(node, list):
return "[" + ", ".join(_stable_ast_repr(item) for item in node) + "]"
return repr(node)


def get_normalized_ast_str(source: str) -> str:
import ast
parsed = ast.parse(source)
Expand All @@ -111,7 +136,9 @@ def get_normalized_ast_str(source: str) -> str:
val = node.body[0].value
if isinstance(val, ast.Constant) and isinstance(val.value, str):
node.body.pop(0)
return ast.dump(parsed)
# NOT ast.dump(parsed): that is interpreter-version-dependent. The custom
# serializer is version-stable so a pinned fingerprint survives a Python bump.
return _stable_ast_repr(parsed)


def fingerprint_source(source: str) -> str:
Expand Down
18 changes: 18 additions & 0 deletions src/legis/store/audit_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Text,
create_engine,
insert,
inspect,
select,
text,
)
Expand Down Expand Up @@ -310,9 +311,22 @@ def append_signed(self, build_payload: BuildSignedPayload) -> int:
conn.execute(text("BEGIN IMMEDIATE"))
return self._insert_signed(conn, build_payload)

def _has_log_table(self, conn) -> bool:
"""True iff the ``audit_log`` table exists on this connection.

An ``initialize=False`` handle opened against an unprovisioned DB (a
missing or empty/no-table file) has no table; reads then treat the store
as empty rather than raising ``OperationalError("no such table")``
(fail-closed: callers map an empty store to their safe default). DDL only
ever runs under ``initialize=True``, so a read can never create it.
"""
return inspect(conn).has_table("audit_log")

def read_all(self) -> list[AuditRecord]:
self._assert_no_batch_in_progress("read_all")
with self._engine.begin() as conn:
if not self._has_log_table(conn):
return []
Comment on lines +328 to +329

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed when the audit table is missing

When any initialized governance/signoff/binding audit database is pointed at an empty file or has its audit_log table dropped out of band, this now returns an empty record set; verify_integrity() then iterates over [] and returns True, so callers such as BindingLedger.verify() and the governance verified_records() path treat a missing trail as a valid, empty one. The posture unprovisioned-DB case needs special handling, but the generic audit store should not silently pass integrity verification when the audit table itself is gone.

Useful? React with 👍 / 👎.

rows = conn.execute(
select(self._log).order_by(self._log.c.seq.asc())
).all()
Expand All @@ -330,6 +344,8 @@ def read_all(self) -> list[AuditRecord]:
def read_by_seq(self, seq: int) -> AuditRecord | None:
self._assert_no_batch_in_progress("read_by_seq")
with self._engine.begin() as conn:
if not self._has_log_table(conn):
return None
row = conn.execute(
select(self._log).where(self._log.c.seq == seq)
).first()
Expand Down Expand Up @@ -428,6 +444,8 @@ def verify_integrity(self) -> bool:
def get_latest_sequence_and_hash(self) -> tuple[int, str]:
self._assert_no_batch_in_progress("get_latest_sequence_and_hash")
with self._engine.begin() as conn:
if not self._has_log_table(conn):
return 0, GENESIS
row = conn.execute(
select(self._log.c.seq, self._log.c.chain_hash)
.order_by(self._log.c.seq.desc())
Expand Down
65 changes: 65 additions & 0 deletions tests/install/test_install_posture.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,71 @@ def test_install_age_file_sink_refuses_without_passphrase(project, monkeypatch):
assert not (project / ".weft" / "legis" / "operator.age").exists()


# ---------------------------------------------------------------------------
# env backend ADOPTS the operator key (dogfood: legis-1844bf8ac9)
# ---------------------------------------------------------------------------
# The --insecure-key-in-env path must use the operator-supplied
# LEGIS_OPERATOR_KEY as the epoch key, NOT mint a throwaway. Minting fresh
# stamps GENESIS with a key the later EnvSigner (which reads LEGIS_OPERATOR_KEY)
# does not hold -> set_floor refuses fingerprint_mismatch -> the floor is a dead
# read-only affordance. Adoption makes the env signer's fingerprint == the epoch.


def test_install_env_backend_adopts_operator_key(project, monkeypatch):
env_key = "a1" * 32 # a known, valid 64-hex operator key
monkeypatch.setenv("LEGIS_OPERATOR_KEY", env_key)

fp = install.install_posture(project, backend="env")

# GENESIS is stamped with the ENV key's fingerprint, not a throwaway minted one.
assert fp == key_fingerprint(env_key)
recs = _records(project)
assert len(recs) == 1
assert recs[0].payload["kind"] == KIND_GENESIS
assert recs[0].payload["key_fingerprint"] == key_fingerprint(env_key)


def test_install_env_backend_floor_writable_with_env_key(project, monkeypatch):
# End-to-end: the epoch the env signer will present must match the GENESIS
# epoch (this is exactly the comparison set_floor makes at step 2). If they
# match, the floor is writable; before the fix they differed every install.
import warnings

from legis.posture import EnvSigner, PostureLedger

env_key = "b2" * 32
monkeypatch.setenv("LEGIS_OPERATOR_KEY", env_key)
install.install_posture(project, backend="env")

led = PostureLedger(install.posture_db_url_for_install(), initialize=False)
epoch_fp = led.current_epoch_fingerprint()

with warnings.catch_warnings():
warnings.simplefilter("ignore") # InsecureEnvKeyWarning is expected
signer = EnvSigner(insecure_env=True)
assert signer.fingerprint() == epoch_fp # no fingerprint_mismatch refusal


def test_install_env_backend_refuses_without_key(project, monkeypatch):
# No LEGIS_OPERATOR_KEY -> fail loud, write no dead GENESIS (fail-closed).
monkeypatch.delenv("LEGIS_OPERATOR_KEY", raising=False)
with pytest.raises(install.OperatorKeyCustodyError):
install.install_posture(project, backend="env")
db = project / ".weft" / "legis" / "legis-posture.db"
if db.exists():
assert _records(project) == []


def test_install_env_backend_refuses_malformed_key(project, monkeypatch):
# A non-hex / wrong-length LEGIS_OPERATOR_KEY must not become a dead epoch.
monkeypatch.setenv("LEGIS_OPERATOR_KEY", "not-a-valid-hex-key")
with pytest.raises(install.OperatorKeyCustodyError):
install.install_posture(project, backend="env")
db = project / ".weft" / "legis" / "legis-posture.db"
if db.exists():
assert _records(project) == []


def test_install_default_backend_selection(project, monkeypatch):
# keychain available -> keychain
monkeypatch.setattr(install, "_keychain_available", lambda: True)
Expand Down
65 changes: 65 additions & 0 deletions tests/policy/test_fingerprint_stability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""The policy-boundary fingerprint must be stable across Python interpreters.

dogfood: legis-13b4e97bf4. The fingerprint canonicalization used to be a raw
``ast.dump`` hash, whose text is Python-version-dependent: 3.13 omits
default-empty AST fields (an empty ``arguments`` node) where 3.12 lists
``posonlyargs`` / ``args`` / ``kwonlyargs`` etc. A fingerprint pinned under one
interpreter then reports a spurious mismatch under another. The fix replaces the
``ast.dump`` text with a serializer that emits EVERY field of every node in a
fixed order, so empty fields cannot be silently dropped — version-stable by
construction.
"""

from __future__ import annotations

import shutil
import subprocess
import sys
from pathlib import Path

import pytest

from legis.policy.decorator import fingerprint_source, get_normalized_ast_str

_REPO_ROOT = Path(__file__).resolve().parents[2]
_SRC = _REPO_ROOT / "src"

# A snippet whose AST has an ``arguments`` node with several empty subfields —
# exactly the shape that differs between ``ast.dump`` on 3.12 vs 3.13.
_SNIPPET = "def t():\n assert True\n"


def test_normalized_ast_str_emits_empty_fields():
# The version-stability invariant: empty argument-list fields are emitted
# explicitly, never dropped (raw ast.dump on 3.13 omits these). This is the
# property that neutralizes the interpreter difference.
dumped = get_normalized_ast_str(_SNIPPET)
for field in ("posonlyargs=[]", "args=[]", "kwonlyargs=[]", "defaults=[]"):
assert field in dumped, f"{field!r} missing from {dumped!r}"


@pytest.mark.skipif(
shutil.which("python3.12") is None, reason="needs python3.12 for cross-interp check"
)
def test_fingerprint_identical_across_python_minor():
# Decisive end-to-end proof: the repo's OWN fingerprint over one fixed source
# must be byte-identical under the in-process interpreter and under
# python3.12. Before the fix these differed; after it they match.
here_fp = fingerprint_source(_SNIPPET)

prog = (
"import sys\n"
f"sys.path.insert(0, {str(_SRC)!r})\n"
"from legis.policy.decorator import fingerprint_source\n"
f"sys.stdout.write(fingerprint_source({_SNIPPET!r}))\n"
)
other = subprocess.run(
["python3.12", "-c", prog],
capture_output=True,
text=True,
)
assert other.returncode == 0, other.stderr
assert other.stdout == here_fp, (
f"fingerprint drifted across interpreters: "
f"{sys.version_info[:2]} -> {here_fp}, 3.12 -> {other.stdout}"
)
Loading
Loading