@polydeukes/core
The protocol every covenant speaks — the input IR, the verdict shape, the config schema, and the telemetry collector.
Beta. A transitive dependency of the umbrella: you do not install it and you do not import it. The consumer entry point is
polydeukes.
What this package owns
Section titled “What this package owns”The protocol every covenant speaks, and nothing that knows what a covenant is about.
| Area | What it is |
|---|---|
| Covenant protocol | The stdin-JSON input IR, the verdict shape, the exit-code contract |
| Config schema | defineConfig() validates parsed yaml/json data; the matching JSON Schema ships as a sibling artifact |
| ROI telemetry | One append-only line collector every package writes through |
| Fail policy | One table deciding fail-open against fail-closed per failure kind |
| Protected-path normalization | The declared list becomes the literal strings the dispatcher matches |
| Transcript seam | The query interface a covenant uses to ask about session history |
Two constraints hold this package’s shape. Zero runtime dependencies — validation is hand-rolled and the published JSON Schema is a sibling artifact the source never reads. No agent, tool, or language literals — editor tool verbs and test-runner names are values supplied by configs and adapters, so the core’s agent-neutrality is a claim a grep can check. Every other package depends on this one; this one depends on none of them.
The session adapter names it as a peerDependency, not a dependency of its
own. It shares the vocabulary with the umbrella rather than installing a copy — SOURCE_KINDS and
parseInput have to be one set of values for the validator and the engine to agree, and two
copies would disagree silently instead of failing at install time. The umbrella carries the
ordinary dependency that satisfies that peer, which is why a consumer still installs one
package and gets core transitively.
The judged protocol
Section titled “The judged protocol”This is the contract the shipped judges speak: a judge receives a CovenantInput — parsed
once from the stdin-JSON payload the surface hands the dispatcher — and answers with a
verdict the wrapper translates into an exit code. Blocked rows in .polydeukes/roi.log
use this judgment vocabulary.
world is supplied by the surface (files, changes, channels). The judge does not
read it from disk. tools and session are supplied by the host: the roster is values the
adapter fills in, and the session is the evidence a live agent session proves that a bare
IR cannot — message freshness and call outcome. transcriptFromSession wraps that key as
the transcript a witness or a precedent consumer reads, the way transcriptFromInput wraps
the bare IR without those two facts.
type CovenantInput = { toolCalls: { name: string; args?: Record<string, unknown>; fileChange?: FileChange }[]; subagentSpawns: { kind: string }[]; userMessages: { text: string }[]; actor?: { agentType?: string }; world?: { files?: Record<string, string>; changes?: string[]; channels?: { sidecar?: string }; }; tools?: { mutating: string[]; shell: string[]; commandArgs: string[] }; session?: { evidencePath?: string; userMessages: { text: string; timestampMs?: number }[]; toolCalls: { name: string; args?: Record<string, unknown>; succeeded?: boolean }[]; channels?: { sidecar?: string }; };};
type FileChange = | { kind: 'create'; path: string; post: string } | { kind: 'modify'; path: string; pre: string; post: string } | { kind: 'delete'; path: string; pre?: string };
type CovenantVerdict = { upheld: true } | { upheld: false; reason: string };The vocabulary carries no tool or agent names. A concrete tool name is a value an adapter
fills into name; kind on a spawn is likewise a value. FileChange is a discriminated
union so that a deletion is first-class evidence rather than an unrepresentable case, and
impossible states — a deletion carrying resulting content, a creation carrying a baseline —
cannot be written down. delete.pre is absent when the baseline was a binary blob, because
a deletion needs no content to be judged. actor is the observation’s actor as the host
envelope proves it — agentType inside a subagent, {} in the main session — and is absent
when the surface proves none; the judge never defaults it.
Evidence has exactly one home: the call it belongs to. fileChange absent means this
call is unproven, and no sibling call’s evidence stands in for it.
function parseInput(stdinJson: string): | { ok: true; value: CovenantInput } | { ok: false; exitCode: 2 };
function verdictToExitCode(verdict: CovenantVerdict): 0 | 1;
function allFileChanges(input: CovenantInput): FileChange[];parseInput never throws. Unparseable JSON, an empty payload, a non-object, a missing
required collection — each resolves to a blocking { ok: false, exitCode: 2 }, so an
unjudgeable input can never be mistaken for a valid one.
verdictToExitCode returns 0 or 1 and never 2. Translating a break into a block is
the wrapper’s policy, not the body’s — see exit codes.
The exported constants are EXIT_UPHOLD (0), EXIT_BREAK_NON_BLOCKING (1), and
EXIT_BREAK_BLOCKING (2).
allFileChanges flattens every call’s evidence in call order for consumers that need no
attribution. Calls without evidence are skipped, never substituted for.
The three discipline lists and the channel derivation
Section titled “The three discipline lists and the channel derivation”defineConfig() accepts three discipline lists and decides, for each entry, whether it is
written in the right one.
| Key | Type | Judged on |
|---|---|---|
disciplines |
(DisciplineEntry | DisciplineDraft)[] |
both surfaces |
sessionDisciplines |
DisciplineEntry[] |
the session surface only |
changeSetDisciplines |
DisciplineEntry[] |
the change-set surface only |
ResolvedConfig carries the three under the same names, holding judged entries only, with the
drafts split out into drafts. A list the input did not declare stays absent rather than
becoming an empty array.
The derivation both the validator and the umbrella use is one exported function:
type DeclarationChannel = 'transcript' | 'channel' | 'command' | 'actor' | 'changes';
function declarationChannels(body: Omit<AlgebraDeclaration, 'discipline'>): DeclarationChannel[];It is pure and syntactic — the declaration is never run. transcript comes from a
{ transcript: true } binding, channel from a { sidecar: true } binding, command from
scope.source === 'command' or a { op: 'source', of: 'command' } step, actor from a
{ op: 'source', of: 'actor' } step, and changes from a
{ op: 'source', of: 'changes' } step. A witness block’s own extract is walked with the
body’s. The returned list is in the fixed order above, so two callers comparing channel sets
compare the same list.
A body naming none of the five reads the changed file and repository files alone, which both
surfaces supply, so it belongs in disciplines. Anything else is a ConfigValidationError
naming the entry, its channels, and the list it belongs in; the messages are in the
configuration reference. The umbrella uses this
function to select the applicable discipline list.
Where the consumer touches it
Section titled “Where the consumer touches it”Three places, all of them indirect.
- The config file. Its schema is defined here. The vocabulary reference is the configuration reference.
- The JSON Schema artifact —
@polydeukes/core/schema.json, an exports subpath, for a project that installs this package directly. A consumer of the umbrella names the copy bundled there instead; both spellings are in configuration.md’s IDE section. - The protocol above — reading a
blockedrow means reading the vocabulary a body answered in.
Everything else here is reached through polydeukes.
Declared limits
Section titled “Declared limits”- Adapter namespaces are validated by shape, not by name.
defineConfig()checks thatadaptersis a map of plain objects and that each namespace value is an object. It does not check that a namespace name is one anybody implements, and it does not look inside the namespace at all. Unknown vocabulary inside a namespace is rejected by that adapter’s own validator, at its own layer — not here. - The default transcript is a noop. A consumer that injects no real transcript converges on “nothing happened”, which is the safe direction for a valve: it never opens. Real transcripts live behind adapters.
- Telemetry is fail-open, alone. A logging failure never changes a verdict. Every other failure kind in the table resolves toward blocking.