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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ __pycache__/
dist/
build/
.pytest_cache/
run-logs/
.zcodeignore
30 changes: 27 additions & 3 deletions docs/design/adapter-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,33 @@ adapters (DSH, openJiuwen are candidates): none shipped
the seam made executable.
3. **Trigger to add an adapter.** A demonstrated native-runner limitation —
realistically, execution continuity beyond an awake host, or dispatch
beyond one machine. Until one shows up, this document keeps the ports
coherent; no plugin machinery, adapter configuration, or dual-runner
support is built.
beyond one machine. Swappability alone never justifies machinery: the
fakes already substitute every port (rule 2). The machinery-grade trigger
is a second party extending nanodot without forking it. Until one shows
up, this document keeps the ports coherent; no plugin machinery, adapter
configuration, or dual-runner support is built.

## Adapter admission discipline

Borrowed from DeepSeek Harness (DSH)'s plugin rules — the discipline, not
the machinery. These bind any future adapter (DSH, openJiuwen, or other):

1. **Declared at boot, validated before state.** An adapter statically
declares which ports it implements, what it depends on, and a config
schema. Boot validates the declaration and fails loudly before any task
state is read or written.
2. **Registrations are effects.** Every lock, sink, listener, or daemon a
registration creates registers a disposer with a teardown registry;
shutdown runs the disposers in reverse registration order. Unload leaves
no residue — no stale lifetime locks, no orphaned notifications (#37).
3. **Provider-visible implies logged.** Nothing reaches an inference
provider that cannot be reconstructed from the activity log. EgressGuard
makes this structural; the egress tripwire test makes it checked. A new
egress field must be whitelisted in docs/design/egress.md and pinned by
that test first.
4. **Typed errors across the seam.** Port errors are typed values
(`FetchError` variants). Core never matches on error prose, and adapter
results are mapped into core types — no string passthrough.

## Ports

Expand Down
46 changes: 44 additions & 2 deletions tests/test_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@

import io
import json
from dataclasses import asdict
from pathlib import Path

import pytest
from fakes import FAILURE, FakeClock, FakeGitHub, FakeProvider, FakeSink

from nanodot.core.activity import ActivityLog
from nanodot.core.egress import EgressGuard
from nanodot.core.egress import EgressGuard, SUMMARIZE_FIELDS
from nanodot.core.redaction import Redactor
from nanodot.core.runner import TaskLoop
from nanodot.core.runner import RunOutcome, TaskLoop, state_change_from_event
from nanodot.core.statemachine import CHECKS_FAILED, WatchEvent
from nanodot.core.tasks import PRTarget, Task, TaskStore
from nanodot.native.inference_api import APIInferenceProvider, configured_provider
Expand Down Expand Up @@ -58,6 +59,47 @@ def test_outbound_values_are_scrubbed_of_secrets(home: Path) -> None:
assert API_KEY not in payload["summary"]


# -- egress tripwire: provider-visible implies activity-logged --------------


def test_egress_is_a_projection_of_the_activity_log(home: Path) -> None:
"""Adapted from DSH's 'model-visible ⟺ logged' invariant
(docs/design/adapter-seam.md, admission discipline 3): every field that
reaches the provider is reconstructable from the activity log. Widening
the provider-visible surface must fail here until it is whitelisted in
docs/design/egress.md and recorded to the log."""
store = TaskStore(path=home / "nanodot.db")
activity = ActivityLog(path=home / "nanodot.db")
github = FakeGitHub(TARGET)
github.set_pr("open", head_sha="s1")
github.add_check("ci", FAILURE, sha="s1")
sink = FakeSink()
provider = FakeProvider()
loop = TaskLoop(store, github, sink, activity, provider=provider)
task = store.create(
Task(target=TARGET, purpose="watch", cadence_seconds=300, next_check_at=0.0)
)

assert loop.run_once(store.get(task.id), 0.0) is RunOutcome.OK
assert provider.summarize_payloads, "a notable event was summarized"

entries = activity.query(task_id=task.id, limit=1000)
for change in provider.summarize_payloads:
# The StateChange surface is exactly the documented whitelist.
assert set(asdict(change)) == set(SUMMARIZE_FIELDS)
# ...and every payload is reconstructable from a log entry.
reconstructed = [
state_change_from_event(
WatchEvent(kind=e.kind, message=e.message, evidence=dict(e.evidence))
)
for e in entries
if e.kind == change.kind and e.message == change.summary
]
assert change in reconstructed, (
f"provider saw {change.kind!r} that the activity log cannot reconstruct"
)


# -- API adapter: same interface, egress-controlled body --------------------


Expand Down
Loading