diff --git a/README.md b/README.md index 3b82413..230fe47 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ execution traces, with retained workspace outputs that can be reviewed before they are selected, applied, released, or discarded. > **Platforms.** Shepherd requires **Python 3.11+**. OS-level grant enforcement -> is exercised on **macOS** (Seatbelt) today; on **Linux**, Landlock enforcement -> is container-gated. **Windows is unsupported** (enforcement would be +> is executed on **both macOS** (Seatbelt) and **Linux** (Landlock, in a privileged +> container). **Windows is unsupported** (enforcement would be > advisory-only at best) — use **WSL**. ## Installation @@ -175,9 +175,9 @@ path-disjoint). > **Scope (P-030 v0.2).** Per-binding whole-profile `ReadOnly`/`ReadWrite` over > disjoint named bindings, on a jailed device, filesystem / Git substrate, -> same-process value-children. Enforcement is exercised on macOS Seatbelt; Linux -> Landlock is container-gated. Sub-root / `where(path=…)` grants are not part of -> this cut. +> same-process value-children. Enforcement is executed on both macOS Seatbelt and +> Linux Landlock (the latter in a privileged container). Sub-root / `where(path=…)` +> grants are not part of this cut. ## Examples diff --git a/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/authority_declarations.py b/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/authority_declarations.py index e3d8c3e..f4a64be 100644 --- a/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/authority_declarations.py +++ b/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/authority_declarations.py @@ -114,6 +114,29 @@ def compile_gitrepo_grant_from_ast_annotation( return None +def gitrepo_grant_spelling(annotation: object) -> str: + """Classify a resolved GitRepo grant annotation's spelling: ``"bare"`` or ``"may"``. + + Recorded as registration provenance beside the compiled grant (P-030 §4 item 4: a bare + handle annotation defaults permissive-for-that-binding and must be recorded so the default + is countable and lintable). Provenance only — the compiled ``GitRepoGrantDescriptor`` and its + content-addressed digest are *identical* for ``GitRepo`` and ``May[GitRepo, ReadWrite]``, so + the spelling is deliberately **not** a descriptor field. Precondition: ``annotation`` already + compiled to a GitRepo grant via :func:`compile_gitrepo_grant_from_annotation`. + """ + return "bare" if annotation is GitRepo else "may" + + +def gitrepo_grant_spelling_from_ast(annotation: ast.expr | None) -> str: + """AST (generated-source) twin of :func:`gitrepo_grant_spelling`. + + Mirrors :func:`compile_gitrepo_grant_from_ast_annotation`'s bare arm (a plain ``GitRepo`` / + ``sp.GitRepo`` name); every other compiling shape is ``May[GitRepo, ...]``. Same precondition: + the annotation already compiled to a grant. + """ + return "bare" if annotation is not None and _expr_name(annotation) == "GitRepo" else "may" + + def raw_annotation_looks_like_authority(annotation: object) -> bool: """Return whether an unresolved annotation appears to carry authority syntax.""" if not isinstance(annotation, str): diff --git a/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py b/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py index 23ffb14..5c45b97 100644 --- a/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py +++ b/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py @@ -35,6 +35,8 @@ AuthorityDeclarationError, compile_gitrepo_grant_from_annotation, compile_gitrepo_grant_from_ast_annotation, + gitrepo_grant_spelling, + gitrepo_grant_spelling_from_ast, raw_annotation_looks_like_authority, ) from shepherd_dialect.workspace_control.drivers import ( @@ -4266,6 +4268,7 @@ def _ast_parameter_schema(arg: ast.arg, *, kind: str, default: ast.expr | None) raise TaskRegistrationError(str(exc)) from exc if gitrepo_grant is not None: parameter_schema["gitrepo_grant"] = gitrepo_grant.to_descriptor() + parameter_schema["gitrepo_grant_spelling"] = gitrepo_grant_spelling_from_ast(arg.annotation) return parameter_schema @@ -4303,6 +4306,7 @@ def _signature_schema(task_body: Callable[..., Any]) -> JsonObject: gitrepo_grant = _gitrepo_grant_from_annotation(annotation, parameter_name=name) if gitrepo_grant is not None: parameter_schema["gitrepo_grant"] = gitrepo_grant.to_descriptor() + parameter_schema["gitrepo_grant_spelling"] = gitrepo_grant_spelling(annotation) parameters.append(parameter_schema) return { "parameters": parameters, diff --git a/shepherd/packages/dialect/tests/test_gitrepo_grant_spelling.py b/shepherd/packages/dialect/tests/test_gitrepo_grant_spelling.py new file mode 100644 index 0000000..925bcbb --- /dev/null +++ b/shepherd/packages/dialect/tests/test_gitrepo_grant_spelling.py @@ -0,0 +1,129 @@ +"""The bare-vs-explicit GitRepo grant spelling is recorded as registration provenance. + +`repo: GitRepo` and `repo: May[GitRepo, ReadWrite]` compile to a *byte-identical* +grant descriptor (same content-addressed digest), so the compiled authority cannot tell +them apart — by design (P-030 §4: bare handle annotations default permissive-for-that-binding, +and the default must be countable/lintable). `gitrepo_grant_spelling` records which syntactic +form was written, in the signature schema, never in the descriptor. + +Serde-safety contract exercised here: the spelling lives in `signature_schema`, and is kept out +of the content-addressed *authority* identity (the `GitRepoGrantDescriptor` digest is identical for +both spellings — asserted below). It *does* feed `schema_digest` (`_task_schema_digest` digests +`signature_schema`), so a 0.3 registration gets a new schema-version identity — but that orphans no +store state, because `schema_digest` is copy-forward provenance: every consumer reads the stored +value off the resolved version record and none recompute-and-compare it. A pre-0.3 record keeps its +stored digest and simply lacks the key (read as "unknown"). +""" + +from __future__ import annotations + +import ast + +import shepherd as sp + +from shepherd_dialect.workspace_control.authority_declarations import ( + gitrepo_grant_spelling, + gitrepo_grant_spelling_from_ast, +) +from shepherd_dialect.workspace_control.workspace import ( + _signature_schema, + _signature_schema_from_ast, +) + + +def _param(schema: dict, name: str) -> dict: + return next(p for p in schema["parameters"] if p["name"] == name) + + +# ── the classifiers, directly ──────────────────────────────────────────────── + + +def test_runtime_spelling_bare_vs_may() -> None: + assert gitrepo_grant_spelling(sp.GitRepo) == "bare" + assert gitrepo_grant_spelling(sp.May[sp.GitRepo, sp.ReadWrite]) == "may" + assert gitrepo_grant_spelling(sp.May[sp.GitRepo, sp.ReadOnly]) == "may" + + +def test_ast_spelling_bare_vs_may() -> None: + bare = ast.parse("GitRepo", mode="eval").body + sp_bare = ast.parse("sp.GitRepo", mode="eval").body + may = ast.parse("May[GitRepo, ReadWrite]", mode="eval").body + assert gitrepo_grant_spelling_from_ast(bare) == "bare" + assert gitrepo_grant_spelling_from_ast(sp_bare) == "bare" + assert gitrepo_grant_spelling_from_ast(may) == "may" + assert ( + gitrepo_grant_spelling_from_ast(None) == "may" + ) # precondition is "a grant exists"; never called on None in practice + + +# ── recorded in the runtime signature schema ───────────────────────────────── + + +def test_runtime_schema_records_bare_spelling() -> None: + @sp.task + def bare(repo: sp.GitRepo, topic: str) -> None: + """Bare writable handle.""" + + schema = _signature_schema(bare._fn if hasattr(bare, "_fn") else bare) + repo_param = _param(schema, "repo") + assert repo_param["gitrepo_grant_spelling"] == "bare" + # A plain value parameter carries neither the grant nor the spelling. + assert "gitrepo_grant" not in _param(schema, "topic") + assert "gitrepo_grant_spelling" not in _param(schema, "topic") + + +def test_runtime_schema_records_may_spelling() -> None: + @sp.task + def explicit(repo: sp.May[sp.GitRepo, sp.ReadWrite], ro: sp.May[sp.GitRepo, sp.ReadOnly]) -> None: + """Explicit grants.""" + + schema = _signature_schema(explicit._fn if hasattr(explicit, "_fn") else explicit) + assert _param(schema, "repo")["gitrepo_grant_spelling"] == "may" + assert _param(schema, "ro")["gitrepo_grant_spelling"] == "may" + + +# ── the load-bearing invariant: spelling is the ONLY discriminator ─────────── + + +def test_bare_and_explicit_readwrite_share_the_descriptor_but_differ_in_spelling() -> None: + @sp.task + def bare(repo: sp.GitRepo) -> None: + """Bare.""" + + @sp.task + def explicit(repo: sp.May[sp.GitRepo, sp.ReadWrite]) -> None: + """Explicit ReadWrite.""" + + bare_param = _param(_signature_schema(bare._fn if hasattr(bare, "_fn") else bare), "repo") + expl_param = _param(_signature_schema(explicit._fn if hasattr(explicit, "_fn") else explicit), "repo") + + # Identical compiled authority (this is why the spelling has to be recorded separately)... + assert bare_param["gitrepo_grant"] == expl_param["gitrepo_grant"] + # ...distinguishable only by the provenance marker. + assert bare_param["gitrepo_grant_spelling"] == "bare" + assert expl_param["gitrepo_grant_spelling"] == "may" + + +# ── recorded on the generated-source (AST) path too ────────────────────────── + + +def test_generated_source_schema_records_spelling() -> None: + source = ( + "import shepherd as sp\n" + "def write(repo: sp.GitRepo, ro: sp.May[sp.GitRepo, sp.ReadOnly], topic: str) -> None:\n" + " 'generated'\n" + ) + schema = _signature_schema_from_ast(ast.parse(source), module_name="gen", qualname="write") + assert _param(schema, "repo")["gitrepo_grant_spelling"] == "bare" + assert _param(schema, "ro")["gitrepo_grant_spelling"] == "may" + assert "gitrepo_grant_spelling" not in _param(schema, "topic") + + +# ── back-compat: a pre-0.3 schema lacks the key and reads as "unknown" ─────── + + +def test_absent_spelling_reads_as_unknown() -> None: + # A record written before 0.3.0 carries the grant but no spelling; readers must treat + # the absent field as "unknown", never infer a value. + legacy_param = {"name": "repo", "gitrepo_grant": {"schema": "…", "grant_ref": "signature:repo", "clauses": []}} + assert legacy_param.get("gitrepo_grant_spelling") is None diff --git a/shepherd/packages/meta/CHANGELOG.md b/shepherd/packages/meta/CHANGELOG.md index 3d82433..6658d08 100644 --- a/shepherd/packages/meta/CHANGELOG.md +++ b/shepherd/packages/meta/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-07-08 + ### Added - **`apply` completes the retained-output settlement vocabulary.** `apply` joins @@ -35,6 +37,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 task-level `may` ceiling is derived from the signature's grants — uniformly, however the task was registered; an explicit `may_default=` still overrides, and the registry records which one happened. +- **Registrations record the `GitRepo` grant spelling.** Each registration records + whether a `GitRepo` grant was written bare (`repo: GitRepo`) or explicit + (`May[GitRepo, ...]`) as registration provenance (`gitrepo_grant_spelling` in the + signature schema). The two compile to a byte-identical grant, so this is the only + discriminator; it feeds the future no-defaulted-grants lint. No behavior change, no + effect on grant identity, enforcement, or the content-addressed task artifact. ### Changed