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
11 changes: 11 additions & 0 deletions .changeset/nested-call-declared-edge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@procedures/client": patch
---

The admission examines each nested call that its caller does not declare. A procedure of code with declared effects answers for a nested call only in one case. An entry of its `metadata.calls` names the callee, and the effects of the callee are in its declaration. `checkEffects()` examines the same edges. Before, such a procedure answered for each of its nested calls, also without `calls`.

Declare each nested callee of a procedure of code in `metadata.calls`. A nested call that the caller does not declare passes only when the capability permits the callee as a direct call.

`examinesNestedCall()` gets the optional parameter `callee`. Without it, the function gives true.

PATCH: a fix of the admission, and a new optional parameter.
4 changes: 3 additions & 1 deletion packages/client/docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ await ctx.client.call(["notes", "list"], {}, { narrow: { clearance: "public" } }

An interceptor can narrow too: `next({ capability: { effects: { writes: [] } } })`. The framework calculates the result, so a request for more has no effect.

The admission examines each call of a host, each nested call of a data-driven procedure (`client.chain` and the other procedures that start refs, because their caller chose the refs), and each nested call with a narrowed capability. A procedure of code that declares its effects answers for its own nested calls. `checkEffects(procedures)` examines those declarations against `metadata.calls`: a declaration must include the reads and the writes of each procedure in its `calls`. Two things are the claim of the caller. The first is what a program that it starts does (the `process` of a callee). The second is the flags `destructive` and `idempotent`, because they depend on the input that the caller gives.
The admission examines each call of a host, each nested call of a data-driven procedure (`client.chain` and the other procedures that start refs, because their caller chose the refs), and each nested call with a narrowed capability. A procedure of code that declares its effects answers for a declared edge only. An entry of its `metadata.calls` names the callee, and the effects of the callee are in its declaration. The admission examines each other nested call, also each nested call of a caller without `calls`.

`checkEffects(procedures)` examines the same edges over the registry: a declaration must include the reads and the writes of each procedure in its `calls`. Two things are the claim of the caller. The first is what a program that it starts does (the `process` of a callee). The second is the flags `destructive` and `idempotent`, because they depend on the input that the caller gives.

## Deadlines

Expand Down
84 changes: 79 additions & 5 deletions packages/client/src/procedures/capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
UNLIMITED_CAPABILITY,
capabilityRefusal,
checkEffects,
examinesNestedCall,
explainInvocation,
ladder,
narrowCapability,
Expand Down Expand Up @@ -49,13 +50,42 @@ function makeRegistry(log: string[] = []): ProcedureRegistry {
registry.register(proc(["files", "read"], () => { log.push("read"); return "content"; }, { effects: READS_FS }));
registry.register(proc(["files", "write"], () => { log.push("write"); return "written"; }, { effects: WRITES_FS }));
registry.register(proc(["files", "unknown"], () => { log.push("unknown"); return "?"; }));
// A procedure of code that declares its effects and uses a helper that declares none
// A procedure that starts a program: its caller declares what the program does
registry.register(proc(["files", "program"], () => { log.push("program"); return "program"; }, { effects: { reads: [], writes: ["process"] } }));
// A procedure of code that declares its effects and its call of the program
registry.register(
proc(["files", "summary"], (_input, ctx) => ctx.client.call(["files", "unknown"], {}), {
proc(["files", "summary"], (_input, ctx) => ctx.client.call(["files", "program"], {}), {
effects: READS_FS,
calls: ["files.program"],
})
);
// A procedure of code that declares its call of a helper that declares no effects
registry.register(
proc(["files", "helped"], (_input, ctx) => ctx.client.call(["files", "unknown"], {}), {
effects: READS_FS,
calls: ["files.unknown"],
})
);
// A procedure of code that declares its effects and no calls, and changes a file
registry.register(
proc(["files", "export"], (_input, ctx) => ctx.client.call(["files", "write"], {}), {
effects: { reads: ["fs"], writes: [] },
})
);
// A procedure of code whose calls do not name the procedure that it calls
registry.register(
proc(["files", "reach"], (_input, ctx) => ctx.client.call(["files", "write"], {}), {
effects: { reads: ["fs"], writes: [] },
calls: ["files.read"],
})
);
// A procedure of code whose calls name the writer with a pattern, but whose effects do not include it
registry.register(
proc(["files", "wide"], (_input, ctx) => ctx.client.call(["files", "write"], {}), {
effects: { reads: ["fs"], writes: [] },
calls: ["files.*"],
})
);
// The same caller, but it narrows its nested call
registry.register(
proc(["files", "narrowing"], (_input, ctx) => ctx.client.call(["files", "unknown"], {}, { narrow: { effects: { writes: [] } } }), {
Expand Down Expand Up @@ -208,11 +238,55 @@ describe("invocations under a capability", () => {
expect(log).toEqual(["read"]);
});

it("lets a procedure of code with declared effects answer for its nested calls", async () => {
it("lets a procedure of code answer for a declared nested call in its effects", async () => {
const log: string[] = [];
const registry = makeRegistry(log);
// As a direct call, a read-only capability refuses the program
expect((await refusal(run(registry, ["files", "program"], {}, { capability: READ_ONLY }))).reason).toBe("effects-exceeded");
expect(await run(registry, ["files", "summary"], {}, { capability: READ_ONLY })).toBe("program");
expect(log).toEqual(["program"]);
});

it("examines each nested call of a caller that declares effects and no calls", async () => {
const log: string[] = [];
const registry = makeRegistry(log);
const error = await refusal(run(registry, ["files", "export"], {}, { capability: READ_ONLY }));
expect(error.code).toBe("FORBIDDEN");
expect(error.reason).toBe("effects-exceeded");
expect(error.message).toContain("files.write");
expect(log).toEqual([]);
// With no capability, the nested call starts as before
expect(await run(registry, ["files", "export"], {})).toBe("written");
expect(log).toEqual(["write"]);
});

it("examines a nested call that the calls of its caller do not name", async () => {
const log: string[] = [];
const error = await refusal(run(makeRegistry(log), ["files", "reach"], {}, { capability: READ_ONLY }));
expect(error.reason).toBe("effects-exceeded");
expect(log).toEqual([]);
});

it("examines a declared nested call whose effects are not in the declaration of its caller", async () => {
const log: string[] = [];
const error = await refusal(run(makeRegistry(log), ["files", "wide"], {}, { capability: READ_ONLY }));
expect(error.reason).toBe("effects-exceeded");
expect(log).toEqual([]);
});

it("examines a declared nested call of a callee that declares no effects", async () => {
const log: string[] = [];
const registry = makeRegistry(log);
expect(await run(registry, ["files", "summary"], {}, { capability: READ_ONLY })).toBe("?");
expect(log).toEqual(["unknown"]);
expect((await refusal(run(registry, ["files", "helped"], {}, { capability: READ_ONLY }))).reason).toBe("effects-undeclared");
expect(log).toEqual([]);
expect(await run(registry, ["files", "helped"], {})).toBe("?");
});

it("examines a nested call when the function does not get the callee", () => {
const caller = proc(["a", "caller"], () => 1, { effects: READS_FS, calls: ["a.*"] });
const callee = proc(["a", "reader"], () => 1, { effects: READS_FS });
expect(examinesNestedCall(caller, READ_ONLY, READ_ONLY, callee)).toBe(false);
expect(examinesNestedCall(caller, READ_ONLY, READ_ONLY)).toBe(true);
});

it("examines a nested call that the caller narrows", async () => {
Expand Down
89 changes: 57 additions & 32 deletions packages/client/src/procedures/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
* A procedure declares its effects in `metadata.effects`. The admission (`capabilityRefusal()`)
* compares them with the permitted effects. It examines each call of a host, each nested call
* of a data-driven procedure (its caller chose the refs), and each nested call with a narrowed
* capability. A procedure of code that declares its effects answers for its own nested calls:
* `checkEffects()` examines those declarations against the call graph, as `metadata.calls`
* makes the reach of a procedure visible.
* capability. A procedure of code that declares its effects answers for each nested call that
* its `metadata.calls` declares, when the effects of the callee are in its own declaration.
* `checkEffects()` examines the same edges against the call graph. The admission examines each
* other nested call.
*/

import type {
Expand Down Expand Up @@ -261,13 +262,55 @@ export function capabilityRefusal(
}

/**
* True when the admission examines a nested call: the caller is data-driven (its caller chose
* the refs), the caller declares no effects, or the capability of the nested call is narrower
* than the capability of its caller. A procedure of code with declared effects answers for its
* own nested calls (`checkEffects()` examines them).
* True when the admission examines a nested call. The admission skips only a declared edge: a
* procedure of code with declared effects calls a procedure that an entry of its `metadata.calls`
* names, with the same capability, and the effects of the callee are in the declaration of the
* caller (`edgeFaults()` gives nothing). A caller without `calls` answers for no nested call.
* Without the callee, the function cannot name the edge, so it gives true.
*/
export function examinesNestedCall(caller: AnyProcedure, callerCapability: Capability<unknown>, capability: Capability<unknown>): boolean {
return isDataDriven(caller) || effectsOf(caller) === undefined || capability !== callerCapability;
export function examinesNestedCall(
caller: AnyProcedure,
callerCapability: Capability<unknown>,
capability: Capability<unknown>,
callee?: AnyProcedure
): boolean {
if (callee === undefined || isDataDriven(caller) || capability !== callerCapability) return true;
const own = effectsOf(caller);
if (own === undefined) return true;
const key = pathToKey(callee.path);
if (!(declaredCalls(caller) ?? []).some((entry) => callMatches(entry, key))) return true;
return edgeFaults(own, callee).length > 0;
}

/** The entries of `metadata.calls` of a procedure, or undefined when it declares none. */
function declaredCalls(procedure: AnyProcedure): readonly string[] | undefined {
const calls = (procedure.metadata as { calls?: unknown } | undefined)?.calls;
return Array.isArray(calls) ? (calls as string[]) : undefined;
}

/**
* The faults of one edge of the call graph: each read or change of the callee that the
* declaration of the caller does not include. A callee that writes `process` starts a program,
* and the caller declares what that program does, so the check skips `process`. A kind that the
* caller does not declare has no limit here: the admission refuses that caller under a capability
* that limits the kind.
*/
function edgeFaults(own: Effects, callee: AnyProcedure): string[] {
const theirs = effectsOf(callee);
if (!theirs) return ["the callee declares no effects"];
const faults: string[] = [];
for (const kind of ["reads", "writes"] as const) {
const mine = own[kind];
const declared = theirs[kind];
if (declared === undefined) {
faults.push(`the callee does not declare what it ${VERB[kind]}`);
continue;
}
if (mine === undefined) continue;
const missing = declared.filter((name) => name !== "process" && !mine.includes(name));
if (missing.length > 0) faults.push(`the declaration does not include ${missing.join(", ")} in ${kind}`);
}
return faults;
}

/** The result of `explainInvocation()`. */
Expand Down Expand Up @@ -321,34 +364,16 @@ export function checkEffects(procedures: readonly AnyProcedure[]): EffectsViolat
const violations: EffectsViolation[] = [];
for (const procedure of procedures) {
const own = effectsOf(procedure);
const calls = (procedure.metadata as { calls?: unknown } | undefined)?.calls;
if (!own || !Array.isArray(calls) || isDataDriven(procedure)) continue;
const calls = declaredCalls(procedure);
if (!own || !calls || isDataDriven(procedure)) continue;
const key = pathToKey(procedure.path);
const callees = new Set<string>();
for (const entry of calls as string[]) {
for (const entry of calls) {
for (const candidate of byKey.keys()) if (callMatches(entry, candidate)) callees.add(candidate);
}
for (const calleeKey of [...callees].sort()) {
const callee = byKey.get(calleeKey)!;
const theirs = effectsOf(callee);
const report = (message: string): void => {
violations.push({ path: procedure.path, callee: calleeKey, message: `${key} calls ${calleeKey}: ${message}` });
};
if (!theirs) {
report("the callee declares no effects");
continue;
}
for (const kind of ["reads", "writes"] as const) {
const mine = own[kind];
const needed = (theirs[kind] ?? []).filter((name) => name !== "process");
if (theirs[kind] === undefined) {
report(`the callee does not declare what it ${VERB[kind]}`);
} else if (mine === undefined) {
continue;
} else {
const missing = needed.filter((name) => !mine.includes(name));
if (missing.length > 0) report(`the declaration does not include ${missing.join(", ")} in ${kind}`);
}
for (const fault of edgeFaults(own, byKey.get(calleeKey)!)) {
violations.push({ path: procedure.path, callee: calleeKey, message: `${key} calls ${calleeKey}: ${fault}` });
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/procedures/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ async function runInvocation<TOutput>(invocation: Invocation, options: InvokeOpt

// The admission: before the input validation, so a refused call learns nothing from its input
const parent = invocation.parent;
if (!parent || examinesNestedCall(parent.procedure, parent.capability, invocation.capability)) {
if (!parent || examinesNestedCall(parent.procedure, parent.capability, invocation.capability, procedure)) {
const refusal = capabilityRefusal(procedure, invocation.capability);
if (refusal?.reason === "deadline") throw new InvocationError("TIMEOUT", refusal.message, path);
if (refusal) throw new InvocationError("FORBIDDEN", refusal.message, path, refusal.reason);
Expand Down
2 changes: 1 addition & 1 deletion packages/client/test/api/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4205,7 +4205,7 @@ export declare function effectsOf(procedure: {
}): Effects | undefined;

### dist/procedures/capability.d.ts#examinesNestedCall
export declare function examinesNestedCall(caller: AnyProcedure, callerCapability: Capability<unknown>, capability: Capability<unknown>): boolean;
export declare function examinesNestedCall(caller: AnyProcedure, callerCapability: Capability<unknown>, capability: Capability<unknown>, callee?: AnyProcedure): boolean;

### dist/procedures/capability.d.ts#explainInvocation
export declare function explainInvocation(path: ProcedurePath, capability: Capability<unknown> | undefined, registry: {
Expand Down
2 changes: 1 addition & 1 deletion packages/documentation/DECISIONS-2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This log records the decisions I made while working without the owner present. E
The owner accepted the default answers of CAPABILITIES-2026-10 and gave the rest of the decisions to the implementation, for generality and the long term. The proposal lists the 14 decisions. The judgment calls that a user sees:

- **`ctx.capability` and `ctx.resources` are required fields of `ProcedureContext`.** A handler gets typed resources without `?`. A test that makes a context by hand adds `capability: {}` and `resources: {}`.
- **A procedure of code with declared effects answers for its nested calls.** The admission does not examine them again, as `metadata.calls` already does for the reach. Without this rule, a read-only session cannot start `git.status`, because it uses `shell.run`.
- **A procedure of code with declared effects answers for its nested calls.** The admission does not examine them again, as `metadata.calls` already does for the reach. Without this rule, a read-only session cannot start `git.status`, because it uses `shell.run`. Since 2026-11-15, the rule covers a declared edge only. The `calls` of the caller name the callee, and the effects of the callee are in its declaration. The admission examines each other nested call.
- **The output filter examines only the output that leaves the host.** A nested result is not filtered, so a parent never gets a stream with holes.
- **`git.remote` is two procedures** (CAP-5 in the status). A caller of `git.remote` with `url` calls `git.remote.set` now.
- **The four findings CAP-1 to CAP-4** stay open, or are a decision (CAP-4): the declarations tell the truth about the code, and a read-only session refuses those procedures.
Expand Down
Loading