Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
902a0e1
test(agent-runtime): specify lifecycle-gated procedural guidance
seonghobae Sep 9, 2026
59f97d4
feat(agent-runtime): gate procedural guidance by execution lifecycle
seonghobae Sep 9, 2026
2ea14b9
merge: carry parent API-doc repair into procedural execution stack
seonghobae Sep 9, 2026
e62bbc2
merge: reconverge procedural execution stack on parent repairs
seonghobae Sep 10, 2026
99cf9d2
merge: carry procedural session brand into execution stack
seonghobae Sep 10, 2026
383f97e
test(agent-runtime): reject forged procedural sessions at lifecycle gate
seonghobae Sep 10, 2026
c25bb73
fix(agent-runtime): require branded procedural session at lifecycle gate
seonghobae Sep 10, 2026
151bf1b
docs(agent-runtime): record lifecycle-gated procedural guidance
seonghobae Sep 10, 2026
fa4b0fd
merge: preserve runtime execution identity in procedural execution stack
seonghobae Sep 10, 2026
513b9ef
fix(agent-runtime): preserve unavailable procedural guidance and roll…
seonghobae Sep 10, 2026
fd9eb69
merge: restack procedural execution on repaired procedural graph
seonghobae Sep 10, 2026
e6d0cf6
test(agent-runtime): distinguish procedural request failures
seonghobae Sep 10, 2026
1a29a01
fix(agent-runtime): separate request errors from lifecycle authority
seonghobae Sep 10, 2026
9535fc6
merge: reconverge procedural execution on canonical architecture
seonghobae Sep 10, 2026
6c396e0
merge: reconverge procedural execution on traceability
seonghobae Sep 10, 2026
b6ca638
merge: reconverge procedural execution on canonical runtime docs
seonghobae Sep 10, 2026
f9ce667
merge: reconverge procedural execution on test and operability docs
seonghobae Sep 10, 2026
4730a0d
merge: reconverge procedural execution on commercial gap baseline
seonghobae Sep 10, 2026
6bb1c7c
docs(research): tighten procedural graph primary-source provenance
seonghobae Sep 10, 2026
18d84eb
test(agent-runtime): reject caller-minted execution errors
seonghobae Sep 10, 2026
abb8304
fix(agent-runtime): keep execution error provenance module-owned
seonghobae Sep 10, 2026
37c4263
merge(agent-runtime): reconverge procedural execution on current grap…
seonghobae Sep 10, 2026
057c821
merge(agent-runtime): carry parent documentation repairs into executi…
seonghobae Sep 10, 2026
761734e
merge(agent-runtime): carry parent adoption-record repair into execut…
seonghobae Sep 10, 2026
94d8f6e
merge(agent-runtime): carry parent documentation regression gate
seonghobae Sep 10, 2026
5351723
merge(agent-runtime): reconverge procedural execution gate on protect…
seonghobae Sep 10, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## Unreleased
- Agent Runtime에 tenant/task/execution-scoped immutable procedural graph와 bounded advisory context, paired held-out candidate screening을 추가한다. 모든 candidate decision은 `activationAuthorized: false`를 유지하고 tool·retry·Policy/Approval·provider routing·credential·foreign-domain authority를 부여하지 않는다. 그래프/평가 wire contract는 아직 Noema-local이며 cross-service publication은 context-graph-contracts의 immutable release를 기다린다. issue #584, ADR 0017.
- Agent Runtime의 procedural guidance를 locally admitted session brand와 canonical execution lifecycle에 결합한다. 구조만 흉내 낸 session은 callback/property를 읽기 전에 거부하고, guidance는 동일 execution의 `running` 상태에서만 반환하며 accepted·cancellation-requested·terminal 상태에서는 context request를 읽지 않고 억제한다. 결과는 계속 `advisory_only`이고 tool·retry·Policy/Approval·transition authority를 만들지 않는다. issue #584.
- External-extension lifecycle의 private Durable Object command surface에 `read_operability`를 추가해 exact stream-scoped SQLite `ctx.storage.sql.databaseSize`를 `{ database_size_bytes }`로만 노출한다. canonical object-name binding이 다르면 409로 거부하고, 음수·비정수 storage counter는 내부 오류로 실패-폐쇄해 #561의 실제 per-object storage-growth evidence producer가 synthetic fixture나 namespace aggregate 대신 deployed object-local byte counter를 소비할 수 있게 한다. 이 경로는 lifecycle event payload·foreign-owner truth·secret·provider routing을 노출하지 않으며 remote p95/contention/recovery, production activation authority, deployment 또는 immutable release acceptance를 대신하지 않는다. issue #561.
- CVE-2026-84373 remediation을 위해 Vitest 개발/테스트 툴체인을 4.1.9에서 패치된 4.1.11 라인으로 올린다(`vitest`, `@vitest/coverage-v8`, canonical `package-lock.json` 재생성, reviewed lockfile change policy, `test/vitest-security-lock.test.ts` 회귀 게이트 포함). Vitest 4.1.11이 끌어온 rolldown 1.2는 WASI 바인딩을 `optionalDependencies`에서 내려도 패키지 자체는 계속 발행하므로, `@rolldown/binding-wasm32-wasi`를 lock 버전에 맞춘 exact devDependency로 명시해 WASI-only patch-validator의 이식성을 유지한다. issue #568.
- Tool / Capability Boundary에 Claude community plugin 외부 확장 승인 포트를 추가한다. 마켓플레이스 메타데이터, 가변 브랜치/태그, Anthropic 리뷰, 플러그인 지시문은 승인 권한이 아니다. exact commit/path/digest, AppGuardrail·격리 영수증, 독립 Noema Policy / Approval, 제품/역할 범위, 만료·롤백, 중복 활성화 재현만 통과한다. Policy / Approval은 명시적 immutable trust input이어야 하며 source-default pilot grant나 합성 owner digest를 production authority로 사용하지 않는다. activation과 invocation replay는 admission port가 실제 발행한 in-process authority만 인정하고, invocation은 activation 이후 시각이어야 하며 activation 범위, live catalog 여섯 identity field, AppGuardrail·quarantine receipt의 현재 존재와 artifact/policy/owner binding을 다시 검증한다. 제품 런타임에서는 플러그인 래퍼를 실행하지 않는다. `context-graph-contracts` 불변 계약이 나오기 전에는 로컬 포트와 테스트 더블만 쓴다. issue #545, ADR 0015.
Expand Down
179 changes: 179 additions & 0 deletions src/agent-runtime/procedural-execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { ExecutionLifecycle, ExecutionState } from "./execution-lifecycle";
import { assertProceduralSession } from "./procedural-graph";
import type { ProceduralContext, ProceduralSession } from "./procedural-graph";
import { isCanonicalExecutionId } from "../runtime-shared/execution-identity";

/** Canonical reason explaining whether one lifecycle-bound execution may receive advisory procedural context without changing runtime authority. */
export type ProceduralExecutionReason =
| "running_execution"
| "execution_not_started"
| "cancellation_requested"
| "terminal_execution"
| "unknown_procedure"
| "context_budget_exceeded";

/** Frozen result binding guidance availability, lifecycle state, graph digest, and optional advisory context to one exact execution identity. */
export interface ProceduralExecutionGuidance {
readonly available: boolean;
readonly reason: ProceduralExecutionReason;
readonly executionId: string;
readonly graphDigest: string;
readonly lifecycleState: ExecutionState;
readonly context: ProceduralContext | null;
}

const executionErrors = new WeakSet<object>();
type ProceduralExecutionErrorCode =
| "invalid_execution_lifecycle"
| "invalid_procedural_session"
| "execution_identity_mismatch"
| "invalid_procedural_request";

const EXECUTION_STATES = new Set<ExecutionState>([
"accepted",
"running",
"cancellation_requested",
"succeeded",
"failed",
"cancelled",
]);

/** Error shape for procedural execution gating; constructing this exported class does not confer module-local error provenance. */
export class ProceduralExecutionError extends Error {
constructor(code: string) {
super(code);
this.name = "ProceduralExecutionError";
}
}

function ownedExecutionError(code: ProceduralExecutionErrorCode): ProceduralExecutionError {
const error = new ProceduralExecutionError(code);
executionErrors.add(error);
return error;
}

function rejectExecution(code: ProceduralExecutionErrorCode): never {
throw ownedExecutionError(code);
}

function normalizeExecutionError(error: unknown): never {
if (typeof error === "object" && error !== null && executionErrors.has(error)) throw error;
throw ownedExecutionError("invalid_execution_lifecycle");
}

function requireProceduralSession(session: unknown): asserts session is ProceduralSession {
try {
assertProceduralSession(session);
} catch {
rejectExecution("invalid_procedural_session");
}
}

function readLifecycle(value: unknown): ExecutionLifecycle {
if (value === null || typeof value !== "object" || Array.isArray(value)) rejectExecution("invalid_execution_lifecycle");
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) rejectExecution("invalid_execution_lifecycle");
const descriptors = Object.getOwnPropertyDescriptors(value);
const keys = Reflect.ownKeys(descriptors);
if (keys.length !== 2 || !Object.hasOwn(descriptors, "executionId") || !Object.hasOwn(descriptors, "state")) {
rejectExecution("invalid_execution_lifecycle");
}
const executionDescriptor = descriptors.executionId;
const stateDescriptor = descriptors.state;
if (!Object.hasOwn(executionDescriptor, "value") || !Object.hasOwn(stateDescriptor, "value")) {
rejectExecution("invalid_execution_lifecycle");
}
const executionId = executionDescriptor.value;
const state = stateDescriptor.value;
if (!isCanonicalExecutionId(executionId) || typeof state !== "string" || !EXECUTION_STATES.has(state as ExecutionState)) {
rejectExecution("invalid_execution_lifecycle");
}
return Object.freeze({executionId, state: state as ExecutionState});
}

function unavailable(
lifecycle: ExecutionLifecycle,
session: ProceduralSession,
reason: Exclude<ProceduralExecutionReason, "running_execution">,
): ProceduralExecutionGuidance {
return Object.freeze({
available: false,
reason,
executionId: lifecycle.executionId,
graphDigest: session.graphDigest,
lifecycleState: lifecycle.state,
context: null,
});
}

function readProceduralContext(session: ProceduralSession, request: unknown): ProceduralContext {
try {
return session.context(request);
} catch {
rejectExecution("invalid_procedural_request");
}
}

/**
* Reads procedural advice only for an actively running execution whose immutable graph session
* is bound to the same canonical execution identity.
*
* The session must carry the module-local runtime admission brand; structural lookalikes are
* rejected before any session property or callback is read. The lifecycle remains authoritative:
* accepted executions receive no pre-start advice, cancellation suppresses further planning, and
* terminal executions never reopen through a procedural suggestion. The request is deliberately
* not inspected in those unavailable states. A running result is still advisory-only because the
* returned context comes from `ProceduralSession`; this adapter does not grant tool, retry,
* approval, or transition authority. Unknown-node and context-budget abstention remain unavailable
* rather than being promoted to successful guidance. Malformed running graph-neighborhood input is
* normalized separately as `invalid_procedural_request` so request defects do not masquerade as
* lifecycle-authority failures. The admitted frozen closure already binds every context to the
* session identity; arbitrary context callbacks are rejected at admission. The caller must supply
* fresh authenticated lifecycle state: this pure function is not a durable revocation store and
* cannot detect a replayed old running snapshot.
*
* @param lifecycle Current Noema lifecycle snapshot produced by the Agent Runtime boundary.
* @param session Execution-pinned procedural graph session created by `startProceduralSession`.
* @param request Localized graph-neighborhood request forwarded only while execution is running.
* @returns Frozen guidance availability and, only for a running execution, advisory context.
*/
export function guideProceduralExecution(
lifecycle: ExecutionLifecycle,
session: ProceduralSession,
request: unknown,
): ProceduralExecutionGuidance {
try {
requireProceduralSession(session);
const retained = readLifecycle(lifecycle);
if (retained.executionId !== session.executionId) {
rejectExecution("execution_identity_mismatch");
}

switch (retained.state) {
case "accepted":
return unavailable(retained, session, "execution_not_started");
case "cancellation_requested":
return unavailable(retained, session, "cancellation_requested");
case "succeeded":
case "failed":
case "cancelled":
return unavailable(retained, session, "terminal_execution");
case "running": {
const context = readProceduralContext(session, request);
if (context.reason === "unknown_procedure" || context.reason === "context_budget_exceeded") {
return unavailable(retained, session, context.reason);
}
return Object.freeze({
available: true,
reason: "running_execution" as const,
executionId: retained.executionId,
graphDigest: session.graphDigest,
lifecycleState: retained.state,
context,
});
}
}
} catch (error) {
return normalizeExecutionError(error);
}
}
118 changes: 118 additions & 0 deletions test/procedural-execution-integrity.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { test } from "vitest";
import assert from "node:assert/strict";
import { createProceduralGraph, startProceduralSession } from "../src/agent-runtime/procedural-graph.ts";
import { ProceduralExecutionError, guideProceduralExecution } from "../src/agent-runtime/procedural-execution.ts";

const contextRequest={lastProcedure:null,hops:2,maxEdges:8};
async function sessionFixture() {
const graphValue=await createProceduralGraph({schemaVersion:"noema.procedural-graph/v1",tenantId:"tenant-a",taskType:"review-task",graphId:"review-graph",revision:1,parentDigest:null,nodes:["Start","review-step","verify-step"],edges:[
{from:"Start",relation:"leads_to",to:"review-step",condition:"",guidance:"Read exact-head evidence",pitfalls:"Advice is not permission"},
{from:"review-step",relation:"requires",to:"verify-step",condition:"",guidance:"Verify finding against source",pitfalls:"Retain source authority"},
]});
return startProceduralSession(graphValue,{tenantId:graphValue.tenantId,taskType:graphValue.taskType,executionId:"run-1",graphDigest:graphValue.digest});
}

for(const lifecycleState of ["accepted","running","cancellation_requested","succeeded","failed","cancelled"]) {
test(`rejects forged session before callback invocation in ${lifecycleState}`,async()=>{
const realSession=await sessionFixture();let callbackCount=0;
const forgedSession={executionId:realSession.executionId,graphDigest:realSession.graphDigest,context(){callbackCount++;return {...realSession.context(contextRequest),authority:"execution_allowed"};}};
assert.throws(()=>guideProceduralExecution({executionId:"run-1",state:lifecycleState},forgedSession,contextRequest),{name:"ProceduralExecutionError",message:"invalid_procedural_session"});
assert.equal(callbackCount,0);
});
}

test("does not invoke a session lookalike accessor",async()=>{
let getterCount=0;
const forgedSession={get executionId(){getterCount++;return "run-1";},graphDigest:"a".repeat(64),context(){throw Error("must not execute");}};
assert.throws(()=>guideProceduralExecution({executionId:"run-1",state:"accepted"},forgedSession,contextRequest),{name:"ProceduralExecutionError",message:"invalid_procedural_session"});
assert.equal(getterCount,0);
});

for(const sessionKind of ["copy","proxy","revoked","null"]) {
test(`rejects ${sessionKind} session without evaluating its behavior`,async()=>{
const realSession=await sessionFixture();let trapCount=0;
const revokedSession=Proxy.revocable(realSession,{});revokedSession.revoke();
const candidateSession=sessionKind==="copy"?{...realSession}:sessionKind==="proxy"?new Proxy(realSession,{get(){trapCount++;throw Error("SECRET");}}):sessionKind==="revoked"?revokedSession.proxy:null;
assert.throws(()=>guideProceduralExecution({executionId:"run-1",state:"running"},candidateSession,contextRequest),{name:"ProceduralExecutionError",message:"invalid_procedural_session"});
assert.equal(trapCount,0);
});
}

for(const [requestValue,reasonCode] of [[{...contextRequest,lastProcedure:"unknown-step"},"unknown_procedure"],[{...contextRequest,maxEdges:1},"context_budget_exceeded"]]) {
test(`propagates graph abstention as unavailable: ${reasonCode}`,async()=>{
const sessionValue=await sessionFixture();
const guidanceValue=guideProceduralExecution({executionId:"run-1",state:"running"},sessionValue,requestValue);
assert.equal(guidanceValue.available,false);
assert.equal(guidanceValue.reason,reasonCode);
assert.equal(guidanceValue.context,null);
assert.equal(guidanceValue.graphDigest,sessionValue.graphDigest);
assert.ok(Object.isFrozen(guidanceValue));
});
}

test("a genuine running session preserves exact advisory identity",async()=>{
const sessionValue=await sessionFixture();
const guidanceValue=guideProceduralExecution({executionId:"run-1",state:"running"},sessionValue,contextRequest);
assert.equal(guidanceValue.available,true);assert.equal(guidanceValue.reason,"running_execution");
assert.equal(guidanceValue.context.authority,"advisory_only");assert.equal(guidanceValue.context.graphDigest,sessionValue.graphDigest);
});

test("a localized terminal procedure does not invent another transition",async()=>{
const sessionValue=await sessionFixture();
const guidanceValue=guideProceduralExecution({executionId:"run-1",state:"running"},sessionValue,{...contextRequest,lastProcedure:"verify-step"});
assert.equal(guidanceValue.available,true);assert.deepEqual(guidanceValue.context.edges,[]);
});

for(const [lifecycleState,reasonCode] of [["accepted","execution_not_started"],["cancellation_requested","cancellation_requested"],["succeeded","terminal_execution"],["failed","terminal_execution"],["cancelled","terminal_execution"]]) {
test(`suppresses graph requests for a genuine ${lifecycleState} execution`,async()=>{
const sessionValue=await sessionFixture();
const requestValue=Proxy.revocable({},{});requestValue.revoke();
const guidanceValue=guideProceduralExecution({executionId:"run-1",state:lifecycleState},sessionValue,requestValue.proxy);
assert.equal(guidanceValue.available,false);assert.equal(guidanceValue.reason,reasonCode);assert.equal(guidanceValue.context,null);
});
}

test("genuine cross-execution session mismatch still fails closed",async()=>{
const sessionValue=await sessionFixture();
assert.throws(()=>guideProceduralExecution({executionId:"run-2",state:"running"},sessionValue,contextRequest),{name:"ProceduralExecutionError",message:"execution_identity_mismatch"});
});

for(const lifecycleValue of [null,undefined,[],new Date(),{executionId:"run-1"},{executionId:"run-1",otherField:"running"},{state:"running",otherField:"run-1"},{executionId:"run-1",state:"running",approved:true},{executionId:"run 1",state:"running"},{executionId:"run-1",state:1},{executionId:"run-1",state:"unknown"}]) {
test(`normalizes malformed lifecycle ${JSON.stringify(lifecycleValue)}`,async()=>{
const sessionValue=await sessionFixture();
assert.throws(()=>guideProceduralExecution(lifecycleValue,sessionValue,contextRequest),{name:"ProceduralExecutionError",message:"invalid_execution_lifecycle"});
});
}
for(const accessorKey of ["executionId","state"]) {
test(`does not invoke lifecycle ${accessorKey} accessor`,async()=>{
const sessionValue=await sessionFixture();let getterCount=0;
const lifecycleValue={executionId:"run-1",state:"running"};
Object.defineProperty(lifecycleValue,accessorKey,{get(){getterCount++;throw Error("SECRET");}});
assert.throws(()=>guideProceduralExecution(lifecycleValue,sessionValue,contextRequest),{name:"ProceduralExecutionError",message:"invalid_execution_lifecycle"});
assert.equal(getterCount,0);
});
}

test("normalizes hostile thrown proxy from lifecycle introspection",async()=>{
const sessionValue=await sessionFixture();const thrownValue=Proxy.revocable({},{});thrownValue.revoke();
const lifecycleValue=new Proxy({},{getPrototypeOf(){throw thrownValue.proxy;}});
assert.throws(()=>guideProceduralExecution(lifecycleValue,sessionValue,contextRequest),{name:"ProceduralExecutionError",message:"invalid_execution_lifecycle"});
});

test("does not trust caller-constructed execution errors thrown by hostile lifecycle input",async()=>{
const sessionValue=await sessionFixture();
const forgedError=new ProceduralExecutionError("attacker_selected_detail");
const lifecycleValue=new Proxy({},{getPrototypeOf(){throw forgedError;}});
assert.throws(()=>guideProceduralExecution(lifecycleValue,sessionValue,contextRequest),{name:"ProceduralExecutionError",message:"invalid_execution_lifecycle"});
});

test("classifies malformed running graph requests separately without leaking raw input",async()=>{
const sessionValue=await sessionFixture();
assert.throws(()=>guideProceduralExecution({executionId:"run-1",state:"running"},sessionValue,{...contextRequest,secretValue:"not an allowed field"}),{name:"ProceduralExecutionError",message:"invalid_procedural_request"});
});

test("accepts null-prototype lifecycle records without changing their identity",async()=>{
const sessionValue=await sessionFixture();
const lifecycleValue=Object.assign(Object.create(null),{executionId:"run-1",state:"running"});
assert.equal(guideProceduralExecution(lifecycleValue,sessionValue,contextRequest).available,true);
});
Loading
Loading