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
53 changes: 47 additions & 6 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,28 @@ An external-write operation also declares a typed `todo_contract` beside
`effect_class`, containing one or more lower-snake `action_kinds` and bounded
`target_key_prefixes`.

When a long-running provider needs LoopX to keep polling its external job, the
same operation may declare a `transition_contract`. This is an authority
allowlist, not a provider-owned Todo schema:

```json
{
"proposal_kinds": [
"continuous_monitor_upsert",
"continuous_monitor_complete"
],
"monitor_key_prefixes": ["example-provider:"],
"monitor_action_kinds": ["poll_external_run"],
"monitor_target_key_prefixes": ["external-run:"],
"monitor_required_capabilities": ["network"]
}
```

The profile bounds every monitor identity, action, external target, and
required capability that the provider may propose. A proposal outside those
bounds fails before any LoopX state write. The provider receives no registry
path and never calls Todo APIs directly.

1. obtain `quota should-run` admission for one exact Goal, Agent, Todo, and
`turn_instance_id`; the selected open Agent Todo's `action_kind` and
`target_key` must match the operation profile's `todo_contract`, so an
Expand All @@ -427,23 +449,42 @@ An external-write operation also declares a typed `todo_contract` beside
idempotency key;
3. call `reconcile_governed_external_capability(...)` until the provider returns
a terminal `loopx_external_effect_receipt_v0`;
4. supply typed writeback and spend callbacks. LoopX reuses the shared Turn
4. let the LoopX Kernel materialize admitted monitor upserts through the normal
Todo APIs. A `running` result may only create or retarget its bounded
continuous monitor, so the recovery entry remains schedulable while the
external operation or its settlement is incomplete;
5. supply typed writeback and spend callbacks. LoopX reuses the shared Turn
settlement driver, requires the effect receipt digest in durable writeback,
and never spends quota before that writeback commits.
and never spends quota before that writeback commits;
6. only after the shared Turn settlement commits, let the Kernel materialize an
admitted terminal monitor completion. A failed writeback or spend therefore
leaves the monitor open for recovery instead of closing the only retry lane.

The provider may return `running`, so a service-side job can outlive the bounded
provider process. Start and reconcile are separately replayable from a mode-0600
journal. Exact provider revision, request digest, Goal binding, settlement
identity, provider effect receipt, writeback receipt, and quota receipt remain
attached to the same invocation. A crash after an external effect therefore
replays with the same idempotency key and reconciles the receipt instead of
starting an unrelated operation. One settlement effect id owns exactly one
identity, transition proposal receipts, provider effect receipt, writeback
receipt, and quota receipt remain attached to the same invocation. Each
materialized proposal is checkpointed immediately. Monitor upserts belong to
the pre-settlement phase; monitor completion belongs to the post-settlement
phase. A crash between either Todo write and its checkpoint recovers by the
proposal's stable monitor key and completion identity instead of duplicating
work. A crash after an external
effect replays with the same idempotency key and reconciles the receipt instead
of starting an unrelated operation. One settlement effect id owns exactly one
material invocation: retrying the same request replays it, while attempting a
different operation or input under the same Turn receipt fails before provider
dispatch. A new invocation also requires `should_run=true`; an existing journal
may still be recovered with its exact typed receipt after the runnable decision
has changed.

Transition proposals are deliberately narrower than arbitrary Goal mutation.
The first version supports only continuous-monitor upsert and completion. It
cannot create a general advancement Todo, change a Goal, widen capability
authority, complete another Agent's work, or reopen a completed monitor. The
Kernel resolves one exact monitor by its admitted binding key, applies the
normal ownership and completion rules, and returns a content-bounded receipt.

Goal enablement alone never grants write authority. Creating or updating the
binding is an explicit Goal configuration change: it uses preview/apply, but it
does not create a Turn or spend Turn quota. Turn scope starts only when an
Expand Down
1 change: 1 addition & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ export function createEffectRuntimeHandlers(
effect_id: requiredString(params.effect_id, "effect_id"),
result_schema: requiredString(params.result_schema, "result_schema"),
effect_class: requiredString(params.effect_class, "effect_class"),
transition_contract: params.transition_contract,
}),
],
[
Expand Down
241 changes: 238 additions & 3 deletions loopx/control_plane/governed_capability.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { JsonObject } from "./effect_program.ts";
import { requireNonEmptyString as requiredString } from "./runtime_decode.ts";
import {
assertNever,
requireNonEmptyString as requiredString,
requireStringLiteral,
} from "./runtime_decode.ts";

export const EXTERNAL_EFFECT_RECEIPT_SCHEMA_VERSION =
"loopx_external_effect_receipt_v0";
export const CONTINUOUS_MONITOR_PROPOSAL_SCHEMA_VERSION =
"loopx_continuous_monitor_proposal_v0";

const RESULT_FIELDS = new Set([
"schema_version",
Expand All @@ -25,6 +31,54 @@ const EFFECT_RECEIPT_FIELDS = new Set([
"evidence_digest",
]);

const TRANSITION_CONTRACT_FIELDS = new Set([
"proposal_kinds",
"monitor_key_prefixes",
"monitor_action_kinds",
"monitor_target_key_prefixes",
"monitor_required_capabilities",
]);
const MONITOR_UPSERT_FIELDS = new Set([
"schema_version",
"proposal_id",
"kind",
"monitor_key",
"text",
"action_kind",
"target_key",
"cadence",
"next_due_at",
"expires_at",
"required_capabilities",
]);
const MONITOR_COMPLETE_FIELDS = new Set([
"schema_version",
"proposal_id",
"kind",
"monitor_key",
"evidence",
]);
const TRANSITION_PROPOSAL_KINDS = [
"continuous_monitor_upsert",
"continuous_monitor_complete",
] as const;
type TransitionProposalKind = typeof TRANSITION_PROPOSAL_KINDS[number];
type ValidatedTransitionProposal =
| (JsonObject & {
kind: "continuous_monitor_upsert";
proposal_id: string;
monitor_key: string;
required_capabilities: string[];
})
| (JsonObject & {
kind: "continuous_monitor_complete";
proposal_id: string;
monitor_key: string;
});
const PROPOSAL_ID_RE = /^[a-z][a-z0-9_.:-]{2,95}$/;
const MONITOR_KEY_RE = /^[a-z][a-z0-9_.-]{0,31}:[a-z][a-z0-9_.:-]{2,95}$/;
const ACTION_KIND_RE = /^[a-z][a-z0-9_]{0,63}$/;

function requiredObject(value: unknown, label: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
Expand All @@ -39,6 +93,29 @@ function boundedArray(value: unknown, label: string): unknown[] {
return [...value];
}

function boundedString(value: unknown, label: string, limit: number): string {
const result = requiredString(value, label);
if (result.length > limit || result.includes("\n") || result.includes("\r")) {
throw new Error(`${label} must be bounded single-line text`);
}
return result;
}

function boundedStringArray(
value: unknown,
label: string,
limit: number,
): string[] {
const result = boundedArray(value, label);
if (
result.length > limit ||
result.some((item) => typeof item !== "string" || item.length === 0)
) {
throw new Error(`${label} must contain bounded non-empty strings`);
}
return result as string[];
}

function requireExactFields(
value: JsonObject,
expected: ReadonlySet<string>,
Expand Down Expand Up @@ -89,6 +166,160 @@ function externalEffectReceipt(
return receipt;
}

function validateTransitionProposals(input: {
value: unknown;
status: string;
transition_contract: unknown;
}): ValidatedTransitionProposal[] {
const proposals = boundedArray(
input.value,
"external capability result transition_proposals",
);
if (proposals.length === 0) {
return [];
}
const contract = requiredObject(
input.transition_contract,
"governed capability transition_contract",
);
requireExactFields(
contract,
TRANSITION_CONTRACT_FIELDS,
"governed capability transition_contract",
);
const allowedKinds = boundedStringArray(
contract.proposal_kinds,
"governed capability transition_contract proposal_kinds",
8,
);
const allowedActions = boundedStringArray(
contract.monitor_action_kinds,
"governed capability transition_contract monitor_action_kinds",
32,
);
const allowedMonitorKeyPrefixes = boundedStringArray(
contract.monitor_key_prefixes,
"governed capability transition_contract monitor_key_prefixes",
32,
);
const allowedTargetPrefixes = boundedStringArray(
contract.monitor_target_key_prefixes,
"governed capability transition_contract monitor_target_key_prefixes",
32,
);
const allowedCapabilities = boundedStringArray(
contract.monitor_required_capabilities,
"governed capability transition_contract monitor_required_capabilities",
32,
);
const seenProposalIds = new Set<string>();
return proposals.map((raw, index) => {
const label = `external capability transition_proposals[${index}]`;
const proposal = requiredObject(raw, label);
const kind: TransitionProposalKind = requireStringLiteral(
proposal.kind,
TRANSITION_PROPOSAL_KINDS,
`${label} kind`,
`${label} kind is unsupported`,
);
if (!allowedKinds.includes(kind)) {
throw new Error(`${label} kind is not admitted by transition_contract`);
}
if (input.status === "running" && kind !== "continuous_monitor_upsert") {
throw new Error(`${label} running result may only upsert a monitor`);
}
if (proposal.schema_version !== CONTINUOUS_MONITOR_PROPOSAL_SCHEMA_VERSION) {
throw new Error(`${label} schema_version is invalid`);
}
const proposalId = boundedString(
proposal.proposal_id,
`${label} proposal_id`,
96,
);
if (!PROPOSAL_ID_RE.test(proposalId) || seenProposalIds.has(proposalId)) {
throw new Error(`${label} proposal_id is invalid or duplicated`);
}
seenProposalIds.add(proposalId);
const monitorKey = boundedString(
proposal.monitor_key,
`${label} monitor_key`,
128,
);
if (!MONITOR_KEY_RE.test(monitorKey)) {
throw new Error(`${label} monitor_key is invalid`);
}
if (!allowedMonitorKeyPrefixes.some((prefix) => monitorKey.startsWith(prefix))) {
throw new Error(`${label} monitor_key is not admitted`);
}
switch (kind) {
case "continuous_monitor_upsert": {
requireExactFields(proposal, MONITOR_UPSERT_FIELDS, label);
const actionKind = boundedString(
proposal.action_kind,
`${label} action_kind`,
64,
);
if (!ACTION_KIND_RE.test(actionKind) || !allowedActions.includes(actionKind)) {
throw new Error(`${label} action_kind is not admitted`);
}
const targetKey = boundedString(
proposal.target_key,
`${label} target_key`,
128,
);
if (!allowedTargetPrefixes.some((prefix) => targetKey.startsWith(prefix))) {
throw new Error(`${label} target_key is not admitted`);
}
const requiredCapabilities = boundedStringArray(
proposal.required_capabilities,
`${label} required_capabilities`,
16,
);
if (
new Set(requiredCapabilities).size !== requiredCapabilities.length ||
requiredCapabilities.some((capability) =>
!ACTION_KIND_RE.test(capability) || !allowedCapabilities.includes(capability)
)
) {
throw new Error(`${label} required_capabilities are not admitted`);
}
return {
...proposal,
kind,
proposal_id: proposalId,
monitor_key: monitorKey,
text: boundedString(proposal.text, `${label} text`, 240),
action_kind: actionKind,
target_key: targetKey,
cadence: boundedString(proposal.cadence, `${label} cadence`, 32),
next_due_at: boundedString(
proposal.next_due_at,
`${label} next_due_at`,
64,
),
expires_at: boundedString(
proposal.expires_at,
`${label} expires_at`,
64,
),
required_capabilities: requiredCapabilities,
};
}
case "continuous_monitor_complete":
requireExactFields(proposal, MONITOR_COMPLETE_FIELDS, label);
return {
...proposal,
kind,
proposal_id: proposalId,
monitor_key: monitorKey,
evidence: boundedString(proposal.evidence, `${label} evidence`, 240),
};
default:
return assertNever(kind, `${label} kind dispatch is incomplete`);
}
});
}

export function validateGovernedCapabilityAdmission(input: {
admission: unknown;
todo_id: string;
Expand Down Expand Up @@ -160,6 +391,7 @@ export function validateGovernedCapabilityResult(input: {
effect_id: string;
result_schema: string;
effect_class: string;
transition_contract?: unknown;
}): { result: JsonObject; journal_status: "running" | "ready_to_settle" } {
if (input.effect_class !== "external_write") {
throw new Error("governed capability execution requires external_write");
Expand All @@ -183,10 +415,14 @@ export function validateGovernedCapabilityResult(input: {
"observations",
"domain_state_mutations",
"domain_transition_receipts",
"transition_proposals",
]) {
result[field] = boundedArray(result[field], `external capability result ${field}`);
}
result.transition_proposals = validateTransitionProposals({
value: result.transition_proposals,
status: result.status as string,
transition_contract: input.transition_contract,
});
result.follow_up = requiredObject(
result.follow_up,
"external capability result follow_up",
Expand All @@ -200,7 +436,6 @@ export function validateGovernedCapabilityResult(input: {
for (const field of [
"domain_state_mutations",
"domain_transition_receipts",
"transition_proposals",
]) {
if ((result[field] as unknown[]).length > 0) {
throw new Error(
Expand Down
Loading
Loading