Skip to content
Draft
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
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
What changed in PACT, newest first. Each entry says what an author can now write, or what now
holds that did not; the commits are listed under it for whoever wants the detail.

## 2026-11 — plugins under the eight laws (11 November)

The plugins kind (D29), rebased onto the seven primitives, now holds the laws through the
interfaces a plugin implements (D29 consequence 7).

- **`plugin.implements:`** names the interfaces (`interface: yes` tools, procedures or sets of
documents) whose code the plugin supplies. A name that is not an interface is refused
(`loader/plugin-implements-what-is-not-an-interface`); a plugin counts as an implementation,
so `loader/an-interface-nothing-implements` no longer fires for one, and it may be a choice of
`chosen-by: run-inputs.<name>`. An agent using an interface only absent plugins implement is
warned (`loader/an-interface-only-an-absent-plugin-implements`).
- **Law 1:** `pact discover`'s `effect` and `moves-money` for an agent include every interface its
plugins implement.
- **Law 3:** an interface action a plugin implements that cannot be undone names
`same-request-key:` (`loader/a-plugin-action-with-no-claim`).
- **Law 8:** `Learner.cycle` holds a `plugins` proposal to `pact check` before holding it for a
person (`checking.check_agent_field`); `explode` keeps a plugin's own `config:` whole, so a
package's camel-case setting names no longer make the document unexplodable.

## 2026-11 — the seven primitives, built (10 November)

`docs/97-PRIMITIVES.md` names seven building blocks (Shape, Origin, Store, Actor, Flow, Check,
Expand Down Expand Up @@ -133,6 +152,32 @@ which also lists what is still pending at run time.
server changed, and a change an approval rests on always stops it
(`mcp_bridge.when_it_changed`). `tool.unlisted-actions: each-call-asks-a-person` offers what the
server adds behind a person.
### Plugins: code a runtime loads, named, versioned and governed (26 October)

- **`plugins:` (D29, narrowing R17).** `plugins/<name>.yaml` asks whatever runs these agents
to load a package: `package:` with its registry (`npm:@acme/crm-tools`,
`pypi:acme-crm-tools`), `version:` as a range in that registry's own grammar, the plugin's
own `config:`, `if-it-cannot-be-loaded:` (`refuse-to-start`, the default, or
`run-without-it`) and `applies-to:` (`the-agents-that-name-it` or `every-agent`); an agent
names the plugins it needs on `plugins:`. `S-EXEC`, so adding one needs a person (D23).
`bundle.brings:` and `needs-a-person-to-approve:` gain `plugins`.
- **`pact check` reads the request and never the package** (R5, D17;
`crates/pact-loader/src/plugins.rs`): `loader/plugin-package-has-no-registry`,
`loader/plugin-version-is-not-a-range` (npm ranges by `nodejs-semver`, pypi specifiers by
`pep440_rs`), `loader/plugin-secret-written-in-the-file` (a key naming a token, secret,
password or API key holds `{by-reference: …}`), and the note
`loader/plugin-runs-only-where-loaded` naming the agents a `refuse-to-start` plugin keeps
to the runtimes that can load it. A plugin nothing names is `loader/nothing-points-at-it`;
`plugins/<name>/plugin.yaml` is the folder form. `pact discover` lists each agent's plugins
with the default written out; a plugin moves the workspace digest and the doc-digest of a
workflow that reaches its agent, never the contract-digest.
- **Both ports refuse or report what they did not load.** A missing `refuse-to-start`
plugin ends the turn before the first model call with `halted: plugins-not-loaded` and
`turn.run.failed`; a missing `run-without-it` one is named on `unenforced`. A host that
loads plugins passes `run(loaded_plugins=)`. Every lattice gains `plugins`, `unsupported`
on every shipped transport, so the conformance report states it before execution.
`examples/plugin-desk/` is the worked example; `tests/trees/a-desk-that-needs-a-plugin/`
and `tests/trees/a-plugin-with-no-registry/` the refusal and the error.

### Tests over recorded runs (18 October)

Expand Down
62 changes: 62 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ serde_json = { version = "1", features = ["preserve_order"] }
thiserror = "2"
unicode-normalization = "0.1"
sha2 = "0.10"
# D29: a plugin's `version:` is held to its own registry's grammar — npm ranges
# as node-semver reads them, pypi specifiers as PEP 440 writes them — rather than
# to a third grammar PACT would have to keep in step with both.
nodejs-semver = { version = "7", default-features = false }
pep440_rs = { version = "0.7", default-features = false }
yaml-rust2 = "0.11"
# 96 §C. The checker for `template-language: jinja`. Pinned EXACTLY, and with
# `unstable_machinery`, because what `holes.rs` reads is the compiled
Expand Down
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,8 @@ never runs a line of yours.

## What you can express

**54 kinds, 384 fields.** 262 are `core` — what a non-technical author writes.
122 are `expert`, and none of them is required for any core capability.
**64 kinds, 545 fields.** 327 are `core` — what a non-technical author writes.
218 are `expert`, and none of them is required for any core capability.

<table>
<tr><th align="left">Capability</th><th align="left">You write</th><th align="left">What it buys</th></tr>
Expand All @@ -339,6 +339,7 @@ never runs a line of yours.
<tr><td><b>Teams</b></td><td><code>team:</code>, <code>teamwork:</code></td><td>Delegation, quorum, races, budget shares — eight worked patterns ship</td></tr>
<tr><td><b>Memory</b></td><td><code>remembers:</code></td><td>A variable a run reads into a call and writes back from one</td></tr>
<tr><td><b>Exact logic</b></td><td><code>programs/</code></td><td>WebAssembly in a locked room, with fuel and a consent gate</td></tr>
<tr><td><b>Runtime plugins</b></td><td><code>plugins/*.yaml</code>, <code>plugins:</code></td><td>Code your runtime loads, named with a version range — refused or reported where it cannot be loaded</td></tr>
<tr><td><b>How you know</b></td><td><code>evals/</code></td><td>The correctness oracle for portability, model swaps and learning</td></tr>
<tr><td><b>Self-improvement</b></td><td><code>learning.yaml</code></td><td>Changes arrive as reviewable diffs, class-gated, never silent</td></tr>
</table>
Expand Down Expand Up @@ -433,8 +434,9 @@ note: 'refund-policy' holds 5 written rules under `## Rules` — lines a person
One folder, loaded by the Rust CLI, executes over **all seven targets** and
produces *byte-identical traces, tool sequences and model-call counts*. The claim
is bounded and the bound is written down: it covers `instructions:`, `tools:`,
`skills:`, `knowledge:`, `team:`, `answers-with:`, the `loop:` and the `limits:`
ceilings — and **not** the twelve governance keys the Node port reports on
`skills:`, `knowledge:`, `team:`, `answers-with:`, the `loop:`, the `limits:`
ceilings and whether a `plugins:` line stops the agent before it starts — and
**not** the twelve governance keys the Node port reports on
`unenforced`, nor the documents under `knowledge/`, which nothing on that port
can look anything up in and which it names on `unretrieved` for every set an
answer did not come from. See
Expand All @@ -446,7 +448,7 @@ inside the claim and reported anyway, since the names are offered to the model
and asking one only comes back as an error there — and every `limits:` key that
port does not read gets its own line under a `limits.` prefix.

The conformance suite compares 30 agents × 7 targets = **180 pairs, 0
The conformance suite compares 31 agents × 7 targets = **186 pairs, 0
divergences**, with every exclusion declared rather than discovered.

Each target takes the framework's **lowest** seam, so none of them owns the loop:
Expand Down Expand Up @@ -538,8 +540,8 @@ for things a framework would have let you do. Declaring `spends-money: yes`
obliges you to say what makes two calls the same call. That is the point, and it
is still friction.

**There is a vocabulary to learn.** Not code, but 54 kinds and 384 fields. The
core is 185 of them and the diagnostics name the exact line to type, but "no
**There is a vocabulary to learn.** Not code, but 64 kinds and 545 fields. The
core is 327 of them and the diagnostics name the exact line to type, but "no
code" is not "nothing to learn".

**The eval suite is work, and it is not optional.** Every portability claim is
Expand Down Expand Up @@ -663,13 +665,13 @@ framework is an extra.

| | |
|---|---|
| **Design** | Thesis, 28 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete |
| **Design** | Thesis, 29 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete |
| **Research** | 14 source-grounded studies, ~15,750 lines, over 140 repos (~15 GB) + 57 papers |
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4683 tests (1556 Rust + 3127 adapter), clippy clean, TypeScript type-checked** |
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4727 tests (1581 Rust + 3146 adapter), clippy clean, TypeScript type-checked** |
| **Adapters** | **All 7 named targets**, proven against one shared conformance suite |

```bash
./scripts/test-all.sh # 4683 tests, Rust + 7 adapters, fully offline
./scripts/test-all.sh # 4727 tests, Rust + 7 adapters, fully offline
```

**What is still owed** is not hidden — it lives in
Expand All @@ -688,7 +690,7 @@ SLOs **report**; they do not stop a run. That is deliberate and
| | |
|---|---|
| [`docs/00-THESIS.md`](docs/00-THESIS.md) | The argument, goals, objectives, acceptance criteria |
| [`docs/01-DECISIONS.md`](docs/01-DECISIONS.md) | 28 binding decisions — **read this before proposing anything** |
| [`docs/01-DECISIONS.md`](docs/01-DECISIONS.md) | 29 binding decisions — **read this before proposing anything** |
| [`docs/30-FRD.md`](docs/30-FRD.md) | 120 functional requirements, each traced to its justification |
| [`docs/50-NOT-COPIED.md`](docs/50-NOT-COPIED.md) | The refusal ledger — what was deliberately not copied, and what would bring each back |
| [`docs/70-PRODUCTION-GAP-REGISTER.md`](docs/70-PRODUCTION-GAP-REGISTER.md) | Every gap between claim and code, with its measurement |
Expand Down
1 change: 1 addition & 0 deletions adapters/out-of-tree/echo_adapter/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def lattice(self) -> dict[str, str]:
# transport that takes the LOOP away — `a2a_transport.py` — is
# `unsupported` there.
"connected_tools": "emulated",
"plugins": "unsupported",
}

#: Whether ANYTHING can put a price on what a call here carried. Nothing
Expand Down
38 changes: 29 additions & 9 deletions adapters/python/src/pact_adapters/checking.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,23 @@ def _agent_key(document: Mapping[str, Any], agent: str) -> str:
raise Uncheckable(f"the document has no agent {agent!r} to put the change in")


def check_instructions(
document: Mapping[str, Any], agent: str, text: str, source: "str | Path | None" = None
def check_agent_field(
document: Mapping[str, Any],
agent: str,
field: str,
value: Any,
source: "str | Path | None" = None,
) -> list[str]:
"""`pact check`'s errors once `agent`'s `instructions:` become `text`.
"""`pact check`'s errors once `agent`'s `field:` becomes `value`.

Through the document when it can be written out; through a copy of the
author's own tree when it carries files the document only references (a
skill's scripts, a program's body), which is the worked example's case."""
skill's scripts, a program's body), which is the worked example's case.
`instructions` is prose and lands in the agent's `instructions.md`; every
other field is a setting and lands in its `agent.yaml`."""
agent = _agent_key(document, agent)
try:
return check_document(with_instructions(document, agent, text))
return check_document(with_field(document, agent, field, value))
except CarriesPayloads:
pass
binary = pact_binary()
Expand All @@ -96,10 +102,24 @@ def check_instructions(
with tempfile.TemporaryDirectory(prefix="pact-proposed-") as scratch:
root = Path(scratch) / "tree"
shutil.copytree(source, root)
_write_instructions(root, agent, text)
if field == "instructions":
_write_instructions(root, agent, value)
else:
_write_setting(root, agent, field, value)
return _checked(binary, root)


def _write_setting(root: Path, agent: str, field: str, value: Any) -> None:
"""Put `field: value` where `agent`'s settings live in a copied tree."""
folder = root / "agents" / agent
settings = folder / "agent.yaml" if folder.is_dir() else root / "agents" / f"{agent}.yaml"
body = yaml.safe_load(settings.read_text()) if settings.exists() else {}
body = body if isinstance(body, dict) else {}
body[field] = value
settings.parent.mkdir(parents=True, exist_ok=True)
settings.write_text(yaml.safe_dump(body, sort_keys=False, allow_unicode=True))


def _write_instructions(root: Path, agent: str, text: str) -> None:
"""Put `text` where `agent`'s instructions live in a copied tree."""
folder = root / "agents" / agent
Expand Down Expand Up @@ -135,11 +155,11 @@ def _checked(binary: Path, root: Path) -> list[str]:
]


def with_instructions(document: Mapping[str, Any], agent: str, text: str) -> dict[str, Any]:
"""A copy of `document` with one agent's `instructions:` replaced."""
def with_field(document: Mapping[str, Any], agent: str, field: str, value: Any) -> dict[str, Any]:
"""A copy of `document` with one agent's `field:` replaced."""
proposed = copy.deepcopy(dict(document))
agents = proposed.setdefault("agents", {})
entry = dict(agents.get(agent) or {})
entry["instructions"] = text
entry[field] = value
agents[agent] = entry
return proposed
25 changes: 21 additions & 4 deletions adapters/python/src/pact_adapters/exploding.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@
STAYS_INLINE = frozenset({"description"})


#: Settings PACT hands over as written and never reads as fields, by
#: `(collection, field)`: a plugin's own `config:` (`spec/schema.yaml`
#: `plugin.config`, `map of anything`). Its keys are the plugin's names and not
#: PACT's (`searchMaxResults` is a setting of the package), so the value stays
#: in the entry's settings file whole: never split into folders, and never held
#: to the portable key alphabet, since none of its keys becomes a file name.
OPAQUE: frozenset[tuple[str, str]] = frozenset({("plugins", "config")})


def _opaque(at: tuple[str, ...], key: str) -> bool:
"""Whether `key` under the path `at` is one of the `OPAQUE` values."""
return len(at) >= 2 and (at[-2], key) in OPAQUE


#: The key the loader puts on a payload folder — a directory it carried
#: verbatim rather than read as fields.
PAYLOAD = "$payload"
Expand Down Expand Up @@ -148,7 +162,8 @@ def _unportable(node: Any, at: tuple[str, ...] = ()) -> dict[str, str]:
why = why_not_portable(str(key))
if why:
found[".".join((*at, str(key)))] = why
found.update(_unportable(value, (*at, str(key))))
if not _opaque(at, str(key)):
found.update(_unportable(value, (*at, str(key))))
elif isinstance(node, list):
for i, value in enumerate(node):
found.update(_unportable(value, (*at, str(i))))
Expand Down Expand Up @@ -202,7 +217,9 @@ def explode(document: Mapping[str, Any], into: "str | Path") -> Explosion:
return said


def _write_map(node: dict, where: Path, said: Explosion, *, top: bool) -> None:
def _write_map(
node: dict, where: Path, said: Explosion, *, top: bool, at: tuple[str, ...] = ()
) -> None:
"""One mapping as a directory: prose to `.md`, the rest to a settings file.

Nested maps become directories with their own settings file, which is the
Expand All @@ -215,7 +232,7 @@ def _write_map(node: dict, where: Path, said: Explosion, *, top: bool) -> None:
path.write_text(value if value.endswith("\n") else value + "\n")
said.wrote.append(str(path))
continue
if isinstance(value, dict) and value and all(
if not _opaque(at, key) and isinstance(value, dict) and value and all(
isinstance(v, dict) for v in value.values()
):
# A map OF maps is a folder of things — `agents/`, `tools/`. A map of
Expand All @@ -226,7 +243,7 @@ def _write_map(node: dict, where: Path, said: Explosion, *, top: bool) -> None:
for name, inner in value.items():
child = folder / name
child.mkdir(parents=True, exist_ok=True)
_write_map(dict(inner), child, said, top=False)
_write_map(dict(inner), child, said, top=False, at=(*at, key, name))
continue
settings[key] = value

Expand Down
Loading
Loading