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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down
129 changes: 129 additions & 0 deletions shepherd/packages/dialect/tests/test_gitrepo_grant_spelling.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions shepherd/packages/meta/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading