fix(evals): refresh native runtime and pin reviewed catalog repairs (#18780)

## Thinking Path

> - Paperclip lets people inspect and manage their agents’ work.
> - The timeline must report canonical ownership history, and OpenCode
tool failures need bounded diagnostics.
> - The measured candidate also had independently reproduced
question-navigation and oracle defects.
> - This change promotes the reviewed production fixes and pins one
immutable, qualified source composition.
> - Original queries, model, counts, clocks, privacy controls and
qualification holds remain intact.

## Linked Issues or Issue Description

Refs #18775, #18777, #18779, #18711, #18713, #18718, #18699, #18798,
#18799.

**What happened?** The measured candidate predates merged native-runtime
fixes and contains fixture defects. The timeline omitted recorded
reassignment/removal history. Finish-input errors lacked a closed
correlation and shape witness.

**Expected behavior:** project recorded ownership transitions without
inventing facts, distinguish successful native execution from failed
verification steps, and retain content-free finish diagnostics.

**Steps to reproduce:** repeat an unchanged assignee while editing
another field; then reassign or unassign it. Only recorded transitions
should appear. Invalid OpenCode finish input should produce a correlated
rejection witness without values or schema text.

Related #18699 adds gated company OTLP diagnostics. This change uses the
existing local callback; it adds no exporter or capture policy.

## What Changed

- Promote canonical timeline creation, reassignment and removal
projection from #18777. Initial child ownership requires a recorded
fresh-creation receipt; delegation requires a readable recorded parent.
Unknown legacy facts fail closed. User actors/owners require active
company membership; agent actors/targets must exist in that company.
Spans, events and edges share that admission filter. Remove the
introduced all-history absence query and use guarded indexed UUID
lookups.
- Add the shared/OpenAPI `unassigned` event kind, preserving newer main
contracts and issue-route/service code through reviewed semantic joins.
Existing UI consumers remain generic; no new visual marker is claimed.
- Add bounded OpenCode finish diagnostics: schema digest, hashed call
identity, closed shape enums and validation category. A throwing sink
does not change admission or cancellation.
- Pin immutable candidate `fd5e3b1e5a379524c0d1651ed8273e75ffd86541`,
composed from combined #18777 source
`b734393eff0d7ef85b7ad8abd556d2a66ca3a4e6` plus the reviewed MCP
diagnostic delta. It preserves actually merged #18775
`97c07d149b5670088faf15317dcf603c75e2af87` and #18779
`a352c057d475067b4fe67375f06785287dfbe0ce`, and merged runtime fixes
#18711, #18713 and #18718.
- Qualify narrative handling for adjacent repair/verification sentences,
intervening native-failure wording, and negated repair objects.
Questions, denials and hypotheticals retain their controls. Preserve
Previous question beside the qualified Entity type import; separately
restore the already-called `internalResultLink` goal helper binding.
That import is a runtime fixture repair, not a type-only change.
- Include the narrow older-baseline CI typecheck closure and causal
heartbeat test observers in the candidate. All four default typecheck
projects execute. Main already includes the protected upstream tooling
and newer heartbeat fixture, so neither is overwritten with an older
candidate file.
- Preserve all 244 identities, original queries, model, native/human
counts and clocks, including the explicit prior Studio accounting
exception. The full catalog stays on its candidate branch; the 196/244
gate and historical SDK runtime holds remain.

## Verification

The primary’s f478 retry repeated the real composer-hidden five-second
assertion (1 failure,18 passes,1 skip). Investigation established a
setup readiness gap: an outer route response can delay mounting the chat
thread beyond the assertion. The successor adds the existing real-thread
`aria-busy="false"` readiness barrier before the unchanged composer
assertion. The original120-second scenario and digest, unread,
source-link, history, and reply checks are unchanged. The full owning
browser scenario passed naturally in10.2s and with its first real chat
response held6000ms in12.0s. This synchronizes fixture setup; the
underlying AWS late-mount latency remains unproved.

**Current main gates passed:** exact head
`823dab49fcd8470140c178771e5c0c6b78725e97` has [successful normal
CI](https://github.com/paperclipai/paperclip/actions/runs/41724620555),
including both required aggregates, fresh Greptile 5/5 and zero
unresolved review threads. Paperclip Review is neutral with a completed
substantive 5/5 and no actionable defect. The current optional
Superagent Security Scan is neutral/inconclusive: Security Graph context
retrieval returned HTTP503, so its assessment did not start. That result
supplies no security PASS credit. The primary source PR retains its
separate current-head gate before Root sequences normal protected
merges.

- Reviewed MCP controls: 130 on main and 129 on the older candidate.
Meaningful parent comparisons failed before implementation/correlation
correction. Controls cover actual offered schema identity, null/omitted
admission, bounded rejection categories and throwing-sink cancellation.
- Exact unchanged production postimages retain 63 Postgres/public-route
controls, nine plan controls and the focused index-plan proof. Owner
OpenAPI passes 32 controls; main’s newer contract passes 33. Earlier
setup/sandbox failures remain recorded; unchanged database controls were
not replayed.
- Narrative owner controls pass 216, retaining all prior 207 controls.
The independent peer passes 144 balanced cases; earlier 108 and 39-case
matrices remain retained. Actual consumer parent failures reproduced the
review findings before repair.
- Main’s server NoEmit passed on its protected baseline; only two pin
files changed afterward. The candidate’s prior full server and
four-project checks passed. All server/package/script/root-lock inputs
remain byte-identical; the changed Runner project’s guard passes on the
current source. Compatibility driver controls pass 47 and six owning
suites pass 82. The one explicitly reviewed Docker-only unit omission
concerns an unimported image-build feature; all four intended project
guards still execute.
- Pin/adapter/contract controls pass 47. All 242 expanded declarations,
the two legacy dispatch rows, and all 244 singleton private policies
retain their authenticated identities. Descriptor, launcher and
private-protocol bytes remain exact; declaration loading invoked zero
fixture callbacks.
- All 506 SDK input blobs match the meaningfully compiled `6abe5739`
source vector. Prior real shared/SDK builds measured declaration/map
changes and zero runtime-JavaScript changes; a compiled consumer accepts
the new kind and rejects an unknown kind. The reviewed registry remains
unchanged. Default source proof, 51 SDK controls, 20 custody controls
and 16 UI consumer controls remain qualified;
runtime/compiler/parser/Zod/canary pins and held runtime grades are
unchanged.
- The full Mac heartbeat run on prior observer source retained **58
passes / six failures in 802.78s**. Two nonassignee controls
subsequently pass focused checks. Four late timeout causes remain
unresolved locally; no flakiness/infrastructure cause or complete local
pass is claimed. [Actual AWS
CI](https://github.com/paperclipai/paperclip/actions/runs/41712023737/job/126182620050)
ran this exact heartbeat file with **64 passes in 107.209s**. Its
server/package inputs are unchanged in the current narrative-only
successor. The source-identical f478 heartbeat file also passed all64
AWS tests in105.812s; source inputs remain unchanged. Current main
ordinary CI passed; the primary source PR must independently close its
latest-head ordinary CI gate.
- Historical main [run
41713049555](https://github.com/paperclipai/paperclip/actions/runs/41713049555)
recorded six synchronized runner-shutdown failures and cancellations;
the shutdown cause is unknown. A separate chat passive-control assertion
observed one `db.transaction` call where zero was expected. Finite local
probes passed 2+2 but did not establish a cause or late stack. No
external publication, cleanup cause, source fix or flakiness is
inferred. The primary’s separate composer-visibility CI failure also
remains an inconclusive historical result; no guessed fix is credited.
No old-head retry was used.
- Earlier UI/inbox fixture failures and the CLI startup timeout remain
historical. UI/inbox repairs are now upstream in #18799/#18798 and main
preserves those files verbatim. The CLI timeout cause remains unknown.
Broader local repository/Rust qualification is not claimed; fresh hosted
CI supplies broader coverage.
- All four actual repair merges, an exact merged-tree/diagnostic source
join, and fresh controller/source/private-policy/active-run guards
precede the ten-case TestOps measurement. No paid or model eval was
launched for this PR; the original three passes/seven failures remain
unchanged.

## Risks

The next ten-case model result is unproved. Earlier MCP timeout and
continuation-shape causes remain unknown; diagnostics provide future
evidence without relaxing validation or rewriting grades. Prior local/CI
failures are retained, including the unresolved chat assertion and local
heartbeat timeouts.

Initial ownership requires canonical in-window audit facts. Missing
legacy facts remain unknown. The additive `unassigned` contract is
synchronized across shared/OpenAPI/server consumers, with no new UI
marker.

SDK runtime holds and the broader 196/244 qualification gate remain.
Normal protected merges require fresh checks and resolved feedback; no
CODEOWNER or branch-protection bypass is authorized here.

## Model Used

OpenAI `gpt-6.1-sol` through Codex. Capabilities used: repository
analysis, code changes, source validation, and local test execution. The
context-window size is not exposed by this harness.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass — current focused/type/pin
controls pass; the prior full Mac heartbeat failures remain disclosed
and receive no local PASS credit
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green — current main normal CI and
required aggregates passed; optional neutral reviews and separate
primary gate are disclosed above
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
exact current main head; full thread pagination verified
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-03 16:17:51 -06:00
1 parent 0ab3ce7a03
commit 683fa4725e
18 files changed
+1069 -111

No files matched your search

+9
View File
@@ -2458,6 +2458,15 @@ pending and continue to block a new run; a longer wait does not certify an outco
The one-hour client deadline does not renew with the Runner lease. If the client
deadline expires while Runner still owns a call, its effect remains unknown
until authoritative reconciliation. Expiry permits neither replay nor attachment.
The OpenCode bridge emits a closed finish-input diagnostic through its existing
optional diagnostic sink. It records the effective offered schema's SHA-256 and,
for at most eight rejected inputs per bridge, the argument root and continuation
presence/type enums. A SHA-256 of the existing call identity permits correlation;
a closed root/continuation/other category distinguishes the failing boundary.
It records no raw call identity, argument values, field names, schema text or
validator messages. A failed sink does not change admission or cancellation.
These facts distinguish a schema/input-shape rejection from a pending MCP call;
they do not identify the submitted value in older attempts without that evidence.
An adopted transport can receive authenticated queued semantic calls before
the driver publishes its recovered session and tool handler. It holds those
calls until the handler is installed, then rechecks the original authority and
+1 -2
View File
@@ -199,8 +199,7 @@ Agent access: `available` — REST / native call_api.
Policy: Same company plus visibility/audit/cost/run permissions where the route requires
them.
Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at
the reviewed commit above.
Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts).
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
@@ -0,0 +1,97 @@
import { createHash } from "node:crypto";
import { afterEach, expect, it, vi } from "vitest";
import { startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
const bridges: OpenCodeMcpBridge[] = [];
afterEach(async () => { await Promise.all(bridges.splice(0).map(bridge => bridge.close())); });
const report = {
reportedWorkDisposition: "done", summary: "Completed the synthetic request.",
completionClaim: { contractRevision: "1", objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] },
evidence: [], verification: [],
};
async function rpc(bridge: OpenCodeMcpBridge, id: number, method: string, params?: unknown) {
return (await fetch(bridge.url, { method: "POST", headers: {
Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json",
}, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) })).json();
}
it("binds closed rejection shapes to the actual offered finish schema without recording values", async () => {
const messages: string[] = [], handler = vi.fn(async () => ({ accepted: true }));
const bridge = await startOpenCodeMcpBridge({ handler, onDiagnostic: message => messages.push(message) });
bridges.push(bridge);
const listed = await rpc(bridge, 1, "tools/list");
const schema = listed.result.tools.find((tool: { name: string }) => tool.name === "paperclip_finish").inputSchema;
const digest = createHash("sha256").update(JSON.stringify(schema)).digest("hex");
for (const [index, input] of [report, { ...report, continuation: null }].entries()) {
const result = await rpc(bridge, index + 2, "tools/call", { name: "paperclip_finish", arguments: input });
expect(result.result.isError).not.toBe(true);
}
for (const [index, continuation] of ["PRIVATE_INPUT_VALUE", [], 42, false].entries()) {
const rejected = await rpc(bridge, index + 4, "tools/call", {
name: "paperclip_finish", arguments: { ...report, continuation },
});
expect(rejected.result.isError).toBe(true);
}
const unrelated = await rpc(bridge, 8, "tools/call", {
name: "paperclip_finish", arguments: { ...report, summary: 42, continuation: null, PRIVATE_FIELD: "PRIVATE_INPUT_VALUE" },
});
expect(unrelated.result.isError).toBe(true);
expect(handler).toHaveBeenCalledTimes(2);
expect(messages.map(message => JSON.parse(message))).toEqual([
{ schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "offered", schemaSha256: digest },
...["string", "array", "number", "boolean"].map((continuationShape, index) => ({
schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected",
schemaSha256: digest, callIdSha256: createHash("sha256").update(String(index + 4)).digest("hex"),
validationCategory: "continuation", rootShape: "object", continuationShape,
})),
{ schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected",
schemaSha256: digest, callIdSha256: createHash("sha256").update("8").digest("hex"),
validationCategory: "other", rootShape: "object", continuationShape: "null" },
]);
expect(JSON.stringify(messages)).not.toMatch(/PRIVATE_INPUT_VALUE|PRIVATE_FIELD|Completed|criteria|continuation.*must|instancePath/);
});
it("reports the effective supplied schema and bounds diagnostics without admitting rejected inputs", async () => {
const messages: string[] = [], handler = vi.fn();
const inputSchema = { type: "object", required: ["continuation"], properties: { continuation: { type: "object" } } };
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "paperclip_finish", inputSchema }], handler,
onDiagnostic: message => messages.push(message),
});
bridges.push(bridge);
for (let index = 0; index < 12; index++) {
const rejected = await rpc(bridge, index, "tools/call", {
name: "paperclip_finish", arguments: index === 0 ? [] : { continuation: null },
});
expect(rejected.result.isError).toBe(true);
}
expect(handler).not.toHaveBeenCalled();
expect(messages).toHaveLength(9);
expect(JSON.parse(messages[0]!)).toMatchObject({ schemaSha256: createHash("sha256").update(JSON.stringify(inputSchema)).digest("hex") });
expect(JSON.parse(messages[1]!)).toMatchObject({ rootShape: "array", continuationShape: "omitted", validationCategory: "root" });
expect(JSON.parse(messages[2]!)).toMatchObject({ rootShape: "object", continuationShape: "null", validationCategory: "continuation" });
expect(messages.every(message => Buffer.byteLength(message) < 448)).toBe(true);
});
it("keeps diagnostics optional and nonfatal while preserving cancellation", async () => {
const aborted = vi.fn();
let started!: () => void;
const begun = new Promise<void>(resolve => { started = resolve; });
const bridge = await startOpenCodeMcpBridge({ timeoutMs: null,
onDiagnostic: () => { throw new Error("unavailable diagnostic sink"); },
handler: ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => { aborted(); reject(new Error("cancelled")); });
started();
}),
});
bridges.push(bridge);
const pending = rpc(bridge, 1, "tools/call", { name: "paperclip_finish", arguments: report });
await begun;
await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) });
expect((await pending).result.isError).toBe(true);
expect(aborted).toHaveBeenCalledOnce();
});
@@ -1,4 +1,4 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
@@ -35,6 +35,7 @@ export interface OpenCodeMcpBridgeOptions {
privateToolTimeoutMs?: number;
maxBodyBytes?: number;
secret?: string;
onDiagnostic?: (message: string) => void;
}
export interface OpenCodeMcpBridge {
@@ -43,6 +44,12 @@ export interface OpenCodeMcpBridge {
close(): Promise<void>;
}
type FinishInputRejection = {
input: unknown;
callId: string;
validationCategory: "root" | "continuation" | "other";
};
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_BODY_BYTES = 1_048_576;
// Native app results/pages are capped at 500 KB before reaching this bridge.
@@ -66,6 +73,25 @@ export async function startOpenCodeMcpBridge(
const privateTools = normalizeTools(options.privateTools ?? [], false);
const admittedTools = [...tools, ...privateTools.filter((candidate) => !tools.some((tool) => tool.name === candidate.name))];
const validators = compileValidators(admittedTools);
const finishSchema = admittedTools.find(tool => tool.name === PRP_COMPLETION_TOOL_NAME)?.inputSchema;
const finishSchemaSha256 = finishSchema && options.onDiagnostic
? createHash("sha256").update(JSON.stringify(finishSchema)).digest("hex") : undefined;
let rejectionDiagnostics = 0;
const diagnoseFinishInput = (rejection?: FinishInputRejection) => {
if (!finishSchemaSha256 || !options.onDiagnostic) return;
if (rejection && rejectionDiagnostics++ >= 8) return;
const diagnostic = {
schema: "paperclip.opencode.finish-input-diagnostic/v1",
stage: rejection ? "input_rejected" : "offered", schemaSha256: finishSchemaSha256,
...(rejection ? {
callIdSha256: createHash("sha256").update(rejection.callId).digest("hex"),
validationCategory: rejection.validationCategory,
rootShape: inputShape(rejection.input),
continuationShape: inputShape(isRecord(rejection.input) ? rejection.input.continuation : undefined),
} : {}),
};
try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change admission. */ }
};
const calls = new Map<string, { fingerprint: string; promise: Promise<unknown> }>();
const controllers = new Map<string, AbortController>();
const server = createServer((request, response) => {
@@ -77,6 +103,7 @@ export async function startOpenCodeMcpBridge(
calls,
controllers,
handler: options.handler,
diagnoseFinishInput,
timeoutMs: options.timeoutMs === null ? null : options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
privateToolNames: new Set(privateTools.map((tool) => tool.name)),
@@ -95,6 +122,7 @@ export async function startOpenCodeMcpBridge(
await closeServer(server);
throw new Error("OpenCode MCP bridge failed to bind a loopback port");
}
diagnoseFinishInput();
return {
url: `http://127.0.0.1:${address.port}/mcp`,
secret,
@@ -105,6 +133,17 @@ export async function startOpenCodeMcpBridge(
};
}
function inputShape(input: unknown): "omitted" | "null" | "array" | "object" | "string" | "number" | "boolean" | "other" {
if (input === undefined) return "omitted";
if (input === null) return "null";
if (Array.isArray(input)) return "array";
if (typeof input === "object") return "object";
if (typeof input === "string") return "string";
if (typeof input === "number") return "number";
if (typeof input === "boolean") return "boolean";
return "other";
}
function normalizeTools(
input: readonly Readonly<Record<string, unknown>>[],
includeTerminalTools = true,
@@ -151,6 +190,7 @@ async function handleRequest(
privateToolTimeoutMs: number;
privateToolNames: ReadonlySet<string>;
maxBodyBytes: number;
diagnoseFinishInput: (rejection?: FinishInputRejection) => void;
},
): Promise<void> {
setSecurityHeaders(response);
@@ -236,6 +276,12 @@ async function handleRequest(
const args = params.arguments ?? {};
const validator = context.validators.get(tool);
if (validator === undefined || !validator(args)) {
if (tool === PRP_COMPLETION_TOOL_NAME) {
const errors = validator?.errors ?? [];
const validationCategory = errors.some(error => error.instancePath === "/continuation" || error.instancePath.startsWith("/continuation/"))
? "continuation" : errors.some(error => error.instancePath === "") ? "root" : "other";
context.diagnoseFinishInput({ input: params.arguments, callId, validationCategory });
}
const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; ");
writeRpc(response, id, {
isError: true,
@@ -2340,6 +2340,7 @@ describe("OpenCodeServerDriver", () => {
const continuation = { kind, summary: "Wait for the saved task path.", idempotencyKey: "task-wait:saved" };
const reads: string[] = [];
const accepted: unknown[] = [];
const diagnostics: string[] = [];
const driver = new OpenCodeServerDriver({
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
@@ -2349,11 +2350,13 @@ describe("OpenCodeServerDriver", () => {
completionContinuation: eligible ? continuation : null };
},
completionFeedback: async result => { accepted.push(result); return "Accepted monitor wait."; },
onDiagnostic: message => diagnostics.push(message),
});
const session = await driver.openSession({ runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: workspace });
try {
await session.startTurn({ message: { role: "user", text: "monitor-default" } });
const events = await collectTurnEvents(session.events());
expect(diagnostics).toContainEqual(expect.stringContaining('"schema":"paperclip.opencode.finish-input-diagnostic/v1","stage":"offered"'));
expect(reads).toEqual(["get_task_context"]);
if (eligible) {
expect(accepted).toMatchObject([{ reportedWorkDisposition: "yielded", continuation }]);
@@ -2436,6 +2436,7 @@ async function startRuntime(input: {
const bridge = await startOpenCodeMcpBridge({
tools: input.options.dynamicTools,
handler: input.dispatch,
onDiagnostic: input.options.onDiagnostic,
...(input.options.durableToolRequests ? { timeoutMs: null } : {}),
}).catch(async (error) => {
await removeIsolatedHome(isolatedHome).catch(() => undefined);
+2 -1
View File
@@ -7,7 +7,8 @@ import type { AgentAppearance } from "../agent-appearance.js";
*/
export type TimelineActorType = "agent" | "user" | "system" | "plugin";
export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned";
/** `unassigned` records an explicit ownership clear and has no target edge. */
export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned" | "unassigned";
export type TimelineEdgeKind = "delegation" | "assignment" | "mention";
export interface WorkTimelineActor {
@@ -0,0 +1,234 @@
import { randomUUID } from "node:crypto";
import type { Express } from "express";
import { eq } from "drizzle-orm";
import request from "supertest";
import { expect, it } from "vitest";
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, documents, documentRevisions, heartbeatRuns, issueComments, issueDocuments, issueThreadInteractions, issues } from "@paperclipai/db";
import { childCreatedByRequest } from "../../services/issue-creation-source.js";
import { issueService } from "../../services/issues.js";
import { createAcceptedPlanDecompositionSchema, createChildIssueSchema } from "@paperclipai/shared";
type Seed = { companyId: string; userId: string; agentAId: string; agentBId: string };
type Fixture = {
getDb: () => ReturnType<typeof createDb>;
seedBase: () => Promise<Seed>;
createApp: (userId?: string, includeIssues?: boolean, actor?: { companyId: string; agentId: string }) => Express;
};
/** Shares the owning suite's real PostgreSQL lifecycle and route actors. */
export function registerTimelineReceiptBoundaryCases(f: Fixture) {
async function acceptedPlanContext(watchdog = false) {
const db = f.getDb(); const base = await f.seedBase();
const sourceId = randomUUID(), documentId = randomUUID(), revisionId = randomUUID();
await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, base.companyId));
await db.insert(issues).values({ id: sourceId, companyId: base.companyId, title: "Approved plan", workMode: "planning",
status: "in_progress", assigneeAgentId: base.agentAId, createdByUserId: base.userId,
...(watchdog ? { originKind: "task_watchdog" } : {}) });
await db.insert(documents).values({ id: documentId, companyId: base.companyId, title: "Plan", format: "markdown", latestBody: "Plan body", latestRevisionId: revisionId, latestRevisionNumber: 1 });
await db.insert(documentRevisions).values({ id: revisionId, companyId: base.companyId, documentId, revisionNumber: 1, title: "Plan", format: "markdown", body: "Plan body" });
await db.insert(issueDocuments).values({ companyId: base.companyId, issueId: sourceId, documentId, key: "plan" });
await db.insert(issueThreadInteractions).values({ companyId: base.companyId, issueId: sourceId, kind: "request_confirmation", status: "accepted", continuationPolicy: "wake_assignee",
payload: { version: 1, prompt: "Approve plan?", target: { type: "issue_document", issueId: sourceId, documentId, key: "plan", revisionId, revisionNumber: 1 } },
result: { version: 1, outcome: "accepted" }, resolvedAt: new Date(), createdByUserId: base.userId, resolvedByUserId: base.userId });
return { ...base, sourceId, revisionId };
}
it("keeps accepted-plan child identities while excluding proven keyed reuse from fresh receipts", async () => {
const db = f.getDb(); const ctx = await acceptedPlanContext(); const svc = issueService(db);
const keyed = { title: "Reused plan task", status: "backlog" as const, assigneeAgentId: ctx.agentBId, idempotencyKey: randomUUID() };
const existing = await svc.createChild(ctx.sourceId, { ...keyed, createdByUserId: ctx.userId });
const result = await svc.decomposeAcceptedPlan(ctx.sourceId, { acceptedPlanRevisionId: ctx.revisionId,
children: [keyed, { title: "Fresh plan task", status: "backlog", assigneeAgentId: ctx.agentBId }], actorUserId: ctx.userId });
expect(result.decomposition.status).toBe("completed");
expect(result.childIssueIds).toHaveLength(2); expect(result.childIssueIds).toContain(existing.issue.id);
expect(result.childIssues.map(issue => issue.id)).toEqual(result.childIssueIds);
expect(result.newlyCreatedIssues).toHaveLength(1);
expect(result.newlyCreatedIssues[0]!.id).not.toBe(existing.issue.id);
const publicInput = createAcceptedPlanDecompositionSchema.parse({ acceptedPlanRevisionId: ctx.revisionId,
children: [{ ...keyed, conversationAgentId: ctx.agentAId, conversationUserId: ctx.userId }] });
for (const unsupported of ["idempotencyKey", "conversationAgentId", "conversationUserId"]) expect(publicInput.children[0]).not.toHaveProperty(unsupported);
});
it("keeps the existing serialized watchdog blocker while supported public plan inputs create a fresh child", async () => {
const db = f.getDb(); const ctx = await acceptedPlanContext(true); const svc = issueService(db);
const keyed = { title: "Existing serialized child", status: "todo" as const, assigneeAgentId: ctx.agentBId, idempotencyKey: randomUUID() };
const existing = await svc.createChild(ctx.sourceId, { ...keyed, createdByUserId: ctx.userId });
const response = await request(f.createApp(ctx.userId, true)).post(`/api/issues/${ctx.sourceId}/accepted-plan-decompositions`)
.send({ acceptedPlanRevisionId: ctx.revisionId, children: [keyed] });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.childIssueIds).toHaveLength(1);
const freshId = response.body.childIssueIds[0];
expect(freshId).not.toBe(existing.issue.id);
expect(response.body.newlyCreatedChildIssueIds).toEqual([freshId]);
expect((await svc.getRelationSummaries(freshId)).blockedBy).toContainEqual(expect.objectContaining({ id: existing.issue.id }));
const parent = await svc.getById(ctx.sourceId);
expect(parent!.status).toBe("blocked");
expect((await svc.getRelationSummaries(ctx.sourceId)).blockedBy).toContainEqual(expect.objectContaining({ id: existing.issue.id }));
const logs = await db.select().from(activityLog);
expect(logs.filter(row => row.entityId === existing.issue.id && row.action === "issue.child_created")).toEqual([]);
expect(logs).toContainEqual(expect.objectContaining({ entityId: freshId, action: "issue.child_created", details: expect.objectContaining({ createdByRequest: true }) }));
expect(logs).toContainEqual(expect.objectContaining({ action: "issue.accepted_plan_decomposition_updated", details: expect.objectContaining({
childIssueIds: [freshId], newlyCreatedChildIssueIds: [freshId], currentSerializedChildIssueId: existing.issue.id,
}) }));
expect(await db.select().from(heartbeatRuns)).toEqual([]);
});
it("projects actual public child creation receipts without admitting unsupported replay keys", async () => {
const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase();
const parentId = randomUUID(), from = new Date(Date.now() - 1000);
await db.update(agents).set({ status: "idle" }).where(eq(agents.companyId, companyId));
await db.insert(issues).values({ id: parentId, companyId, title: "Parent", status: "backlog", assigneeAgentId: agentAId, createdByUserId: userId, createdAt: from });
const body = { title: "Canonical child", status: "backlog", assigneeAgentId: agentBId, idempotencyKey: randomUUID() };
expect(createChildIssueSchema.parse(body)).not.toHaveProperty("idempotencyKey");
const created = await request(f.createApp(userId, true, { companyId, agentId: agentAId })).post(`/api/issues/${parentId}/children`).send(body);
expect(created.status, JSON.stringify(created.body)).toBe(201);
const id = created.body.id;
const receipt = (await db.select().from(activityLog).where(eq(activityLog.entityId, id))).find(row => row.action === "issue.child_created")!;
expect(receipt).toMatchObject({ actorType: "agent", actorId: agentAId, details: { parentId, assigneeAgentId: agentBId, assigneeUserId: null, createdByRequest: true } });
const response = await request(f.createApp(userId)).get(`/api/companies/${companyId}/timeline`).query({ from: from.toISOString(), to: new Date().toISOString() });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.edges.filter((edge: any) => edge.issueId === id)).toEqual([
{ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId: id, at: receipt.createdAt.toISOString(), kind: "assignment" },
{ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId: id, at: receipt.createdAt.toISOString(), kind: "delegation" },
]);
expect(await db.select().from(heartbeatRuns)).toEqual([]);
});
it("keeps private-service replay facts separate from original child ownership, including missing originals", async () => {
const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); const svc = issueService(db);
const parentId = randomUUID(), laterAgentId = randomUUID(), from = new Date(Date.now() - 1000);
await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, companyId));
await db.insert(agents).values({ id: laterAgentId, companyId, name: "Later worker", adapterType: "codex_local", status: "paused" });
await db.insert(issues).values({ id: parentId, companyId, title: "Private service parent", status: "backlog", createdByUserId: userId, createdAt: from });
const body = { title: "Keyed private child", status: "backlog" as const, assigneeAgentId: agentBId, createdByAgentId: agentAId, idempotencyKey: randomUUID() };
const proposed = randomUUID(); const created = await svc.createChild(parentId, { ...body, id: proposed });
expect(created.issue.id).toBe(proposed);
const firstAt = new Date();
const [first] = await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: created.issue.id, createdAt: firstAt,
details: { parentId, assigneeAgentId: created.issue.assigneeAgentId, assigneeUserId: created.issue.assigneeUserId, createdByRequest: childCreatedByRequest(proposed, created.issue.id) } }).returning();
const boardApp = f.createApp(userId, true);
const changed = await request(boardApp).patch(`/api/issues/${created.issue.id}`).send({ assigneeAgentId: laterAgentId });
expect(changed.status, JSON.stringify(changed.body)).toBe(200);
const replayProposal = randomUUID(); const replayed = await svc.createChild(parentId, { ...body, id: replayProposal });
expect(replayed.issue.id).toBe(created.issue.id); expect(replayed.issue.id).not.toBe(replayProposal);
expect(replayed.issue.assigneeAgentId).toBe(laterAgentId);
const replayAt = new Date();
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: replayed.issue.id, createdAt: replayAt,
details: { parentId, assigneeAgentId: replayed.issue.assigneeAgentId, assigneeUserId: replayed.issue.assigneeUserId, createdByRequest: childCreatedByRequest(replayProposal, replayed.issue.id) } });
const timeline = async (start: Date) => {
const response = await request(boardApp).get(`/api/companies/${companyId}/timeline`).query({ from: start.toISOString(), to: new Date().toISOString() });
expect(response.status, JSON.stringify(response.body)).toBe(200); return response.body;
};
const full = await timeline(from);
expect(full.edges.filter((edge: any) => edge.issueId === created.issue.id && edge.kind === "delegation")).toEqual([
{ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId: created.issue.id, at: firstAt.toISOString(), kind: "delegation" },
]);
expect(full.edges.filter((edge: any) => edge.issueId === created.issue.id && edge.kind === "assignment")).toHaveLength(2);
expect(first!.details).toMatchObject({ createdByRequest: true });
expect(childCreatedByRequest(replayProposal, replayed.issue.id)).toBe(false);
await db.delete(activityLog).where(eq(activityLog.id, first!.id));
for (const start of [from, replayAt]) {
const view = await timeline(start);
expect(view.edges.some((edge: any) => edge.issueId === created.issue.id && edge.fromActorId === `agent:${agentAId}`)).toBe(false);
expect(view.events.some((event: any) => event.issueId === created.issue.id && event.kind === "delegated")).toBe(false);
}
expect(await db.select().from(heartbeatRuns)).toEqual([]);
});
it.each(["missing", "malformed"])("does not rescue an unknown earliest bounded child receipt (%s) with a later receipt", async kind => {
const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase();
const parentId = randomUUID(), childId = randomUUID();
await db.insert(issues).values([
{ id: parentId, companyId, title: "Parent", status: "done", createdByUserId: userId, createdAt: new Date("2026-03-01T10:00:00Z") },
{ id: childId, companyId, parentId, title: "Child", status: "done", createdAt: new Date("2026-03-01T11:00:00Z") },
]);
const details = { parentId, createdByRequest: true, assigneeAgentId: agentBId, assigneeUserId: null };
await db.insert(activityLog).values([
{ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: childId, createdAt: new Date("2026-03-01T11:00:00Z"), details: kind === "missing" ? { parentId, createdByRequest: true } : { ...details, assigneeAgentId: 42 } },
{ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: childId, createdAt: new Date("2026-03-01T12:00:00Z"), details },
]);
const response = await request(f.createApp()).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status).toBe(200); expect(response.body.edges).toEqual([]);
});
it.each([undefined, false, "true"])("does not treat an unproven child receipt as creation: %j", async flag => {
const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase();
const parentId = randomUUID(), childId = randomUUID(), at = new Date("2026-03-01T11:00:00Z");
await db.insert(issues).values([
{ id: parentId, companyId, title: "Parent", status: "done", createdByUserId: userId, createdAt: new Date("2026-03-01T10:00:00Z") },
{ id: childId, parentId, companyId, title: "Child", status: "done", createdByAgentId: agentAId, createdAt: at },
]);
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.child_created", entityType: "issue", entityId: childId, createdAt: at,
details: { parentId, assigneeAgentId: agentBId, assigneeUserId: null, ...(flag === undefined ? {} : { createdByRequest: flag }) } });
const response = await request(f.createApp()).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status).toBe(200); expect(response.body.edges).toEqual([]);
});
it.each(["actor", "target"] as const)("keeps valid active same-company user %s facts", async role => {
const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase();
const id = randomUUID(), at = new Date("2026-03-01T11:00:00Z");
await db.insert(issues).values({ id, companyId, title: "Valid user facts", status: "done", createdByUserId: userId, createdAt: at });
await db.insert(activityLog).values({ companyId, actorType: role === "actor" ? "user" : "agent", actorId: role === "actor" ? userId : agentAId,
action: "issue.created", entityType: "issue", entityId: id, createdAt: at, details: { parentId: null,
assigneeAgentId: role === "target" ? null : agentBId, assigneeUserId: role === "target" ? userId : null } });
const response = await request(f.createApp()).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status).toBe(200); expect(response.body.edges).toHaveLength(1);
expect(response.body.actors).toContainEqual(expect.objectContaining({ id: `user:${userId}`, name: "User One" }));
});
it.each(["foreign-actor", "foreign-target", "inactive-actor", "inactive-target", "resolver"] as const)("does not expose out-of-bound user identity through %s", async kind => {
const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase();
const foreignCompanyId = randomUUID(), privateUserId = randomUUID(), id = randomUUID(), at = new Date("2026-03-01T11:00:00Z");
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign user company", issuePrefix: "FU" });
await db.insert(authUsers).values({ id: privateUserId, name: "Foreign private name", image: "https://private.example/avatar", email: `${privateUserId}@example.test`, emailVerified: true, createdAt: at, updatedAt: at });
await db.insert(companyMemberships).values({ companyId: kind.startsWith("inactive") ? companyId : foreignCompanyId,
principalType: "user", principalId: privateUserId, status: kind.startsWith("inactive") ? "inactive" : "active", membershipRole: "operator" });
await db.insert(issues).values({ id, companyId, title: "Scoped user facts", status: "done", createdByUserId: kind === "resolver" ? privateUserId : userId, createdAt: at });
const actor = kind.endsWith("actor"); const target = kind.endsWith("target");
if (kind === "resolver") {
await db.insert(issueComments).values({ companyId, issueId: id, authorUserId: privateUserId, body: "Recorded comment", createdAt: at });
} else {
await db.insert(activityLog).values({ companyId, actorType: actor ? "user" : "agent", actorId: actor ? privateUserId : agentAId,
action: "issue.created", entityType: "issue", entityId: id, createdAt: at,
details: { parentId: null, assigneeAgentId: target ? null : agentBId, assigneeUserId: target ? privateUserId : null } });
}
const response = await request(f.createApp()).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
const json = JSON.stringify(response.body);
for (const secret of [privateUserId, "Foreign private name", "https://private.example/avatar"]) expect(json).not.toContain(secret);
const actorIds = new Set(response.body.actors.map((row: any) => row.id));
expect(response.body.events.every((row: any) => actorIds.has(row.actorId))).toBe(true);
expect(response.body.edges.every((row: any) => actorIds.has(row.fromActorId) && actorIds.has(row.toActorId))).toBe(true);
});
it.each(["foreign-actor", "missing-actor", "foreign-target", "missing-target", "resolver"] as const)("does not expose out-of-bound agent identity through %s", async kind => {
const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase();
const foreignCompanyId = randomUUID(), privateAgentId = randomUUID(), id = randomUUID(), at = new Date("2026-03-01T11:00:00Z");
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign agent company", issuePrefix: "FA" });
if (!kind.startsWith("missing")) await db.insert(agents).values({ id: privateAgentId, companyId: foreignCompanyId, name: "Foreign private agent", adapterType: "codex_local", status: "paused" });
await db.insert(issues).values({ id, companyId, title: "Scoped agent facts", status: "done", createdByAgentId: kind === "resolver" ? privateAgentId : agentAId, createdAt: at });
if (kind === "resolver") {
await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: privateAgentId, body: "Recorded comment", createdAt: at });
await db.insert(heartbeatRuns).values({ companyId, agentId: privateAgentId, status: "succeeded", invocationSource: "assignment", contextSnapshot: { issueId: id }, createdAt: at, startedAt: at, finishedAt: at });
} else {
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: kind.endsWith("actor") ? privateAgentId : agentAId,
action: "issue.updated", entityType: "issue", entityId: id, createdAt: at,
details: { assigneeAgentId: kind.endsWith("target") ? privateAgentId : agentBId } });
}
// Valid company-local assignment/run remains visible beside rejected facts.
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.updated", entityType: "issue", entityId: id, createdAt: at, details: { assigneeAgentId: agentBId } });
const [validRun] = await db.insert(heartbeatRuns).values({ companyId, agentId: agentBId, status: "succeeded", invocationSource: "assignment", contextSnapshot: { issueId: id }, createdAt: at, startedAt: at, finishedAt: at }).returning();
const response = await request(f.createApp(userId)).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
const json = JSON.stringify(response.body);
for (const secret of [privateAgentId, "Foreign private agent"]) expect(json).not.toContain(secret);
expect(response.body.edges).toContainEqual(expect.objectContaining({ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, kind: "assignment" }));
expect(response.body.spans).toContainEqual(expect.objectContaining({ runId: validRun!.id, actorId: `agent:${agentBId}` }));
const actorIds = new Set(response.body.actors.map((row: any) => row.id));
expect(response.body.events.every((row: any) => actorIds.has(row.actorId))).toBe(true);
expect(response.body.spans.every((row: any) => actorIds.has(row.actorId))).toBe(true);
expect(response.body.edges.every((row: any) => actorIds.has(row.fromActorId) && actorIds.has(row.toActorId))).toBe(true);
});
}
@@ -1428,3 +1428,12 @@ describe("heartbeat run ID OpenAPI contract", () => {
expect(checked).toBe(12);
});
});
it("documents explicit owner removals in the timeline response event enum", () => {
const spec = buildOpenApiSpec() as any;
const schema = spec.paths["/api/companies/{companyId}/timeline"].get.responses["200"].content["application/json"].schema;
expect(schema.properties.events.items.properties.kind.enum).toEqual([
"created", "commented", "approved", "delegated", "assigned", "unassigned",
]);
});
@@ -1,13 +1,20 @@
import { randomUUID } from "node:crypto";
import express from "express";
import { eq, sql } from "drizzle-orm";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentWakeupRequests,
agents,
approvals,
authUsers,
companies,
companyMemberships,
documents,
documentRevisions,
issuePlanDecompositions,
issueThreadInteractions,
createDb,
heartbeatRuns,
issueApprovals,
@@ -19,7 +26,10 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { companyRoutes } from "../routes/companies.js";
import { buildOpenApiSpec } from "../routes/openapi.js";
import { registerTimelineReceiptBoundaryCases } from "./helpers/work-timeline-receipt-cases.js";
import { normalizeTimelineWindow, workTimelineService } from "../services/work-timeline.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@@ -46,8 +56,14 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
await db.delete(issueApprovals);
await db.delete(approvals);
await db.delete(heartbeatRuns);
await db.delete(issuePlanDecompositions);
await db.delete(issueThreadInteractions);
await db.delete(issues);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(agentWakeupRequests);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(authUsers);
await db.delete(companies);
});
@@ -56,13 +72,13 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
await tempDb?.cleanup();
});
function createApp() {
function createApp(actorUserId = "local-board", includeIssues = false, agentActor?: { companyId: string; agentId: string }) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
(req as any).actor = agentActor ? { type: "agent", source: "agent_key", ...agentActor } : {
type: "board",
userId: "local-board",
userId: actorUserId,
companyIds: [],
memberships: [],
source: "local_implicit",
@@ -71,6 +87,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
next();
});
app.use("/api/companies", companyRoutes(db, {} as any));
if (includeIssues) app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
@@ -94,6 +111,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
});
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
await db.insert(agents).values([
{
id: agentAId,
@@ -131,7 +149,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
expect(result.from.toISOString()).toBe("2026-02-12T00:00:00.000Z");
});
it("aggregates runs, human events, approvals, and delegation edges", async () => {
it("aggregates runs, human events, approvals, and recorded assignments", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const parentIssueId = randomUUID();
const childIssueId = randomUUID();
@@ -251,15 +269,33 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
"created",
"commented",
"approved",
"delegated",
"assigned",
]));
expect(result.edges).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "delegation", issueId: childIssueId }),
expect.objectContaining({ kind: "assignment", issueId: childIssueId }),
]));
});
it("does not retain an inferred delegation when assignment history was recorded", async () => {
const { companyId, agentAId, agentBId } = await seedBase();
const parentId = randomUUID(), childId = randomUUID();
await db.insert(issues).values([
{ id: parentId, companyId, title: "Parent", status: "done", assigneeAgentId: agentAId,
createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") },
{ id: childId, companyId, parentId, title: "Child", status: "done", assigneeAgentId: agentBId,
createdAt: new Date("2026-03-03T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") },
]);
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId,
action: "issue.assigned", entityType: "issue", entityId: childId,
details: { assigneeAgentId: agentBId }, createdAt: new Date("2026-03-05T12:00:00Z") });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
expect(response.status).toBe(200);
expect(response.body.events).toContainEqual(expect.objectContaining({ issueId: childId, kind: "assigned" }));
expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]);
expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "delegation")).toEqual([]);
});
it("preserves delegation edges when parent and child cross a page boundary", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const parentIssueId = randomUUID();
@@ -291,6 +327,11 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
},
]);
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId,
action: "issue.created", entityType: "issue", entityId: childIssueId,
createdAt: new Date("2026-03-01T11:00:00Z"), details: { parentId: parentIssueId,
assigneeAgentId: agentBId, assigneeUserId: null, source: "paperclip_runner_protocol" } });
const result = await workTimelineService(db).getTimeline({
companyId,
from: new Date("2026-03-01T00:00:00Z"),
@@ -508,6 +549,236 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
expect(maxActiveChecks).toBeLessThanOrEqual(16);
});
it.each(["before", "after"] as const)("does not invent creation ownership from a reassignment %s the query window", async when => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const issueId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Historical launch owner",
status: "done", priority: "medium", assigneeAgentId: agentBId, createdByUserId: userId,
createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") });
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId,
entityType: "issue", entityId: issueId, action: "issue.updated",
createdAt: new Date(when === "before" ? "2026-03-02T10:00:00Z" : "2026-03-06T10:00:00Z"),
details: { assigneeAgentId: agentBId, changes: { assigneeAgentId: { from: agentAId, to: agentBId } } } });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ issueId, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([]);
expect(response.body.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([]);
expect(response.body.events).toContainEqual(expect.objectContaining({ issueId, kind: "created" }));
});
it.each([
["child", "2026-03-02T10:00:00Z"], ["child", "2026-03-05T12:00:00Z"],
["parent", "2026-03-02T10:00:00Z"], ["parent", "2026-03-06T10:00:00Z"],
] as const)("does not infer a creation delegation after %s ownership changed at %s", async (changedIssue, at) => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const parentId = randomUUID(), childId = randomUUID();
await db.insert(issues).values([
{ id: parentId, companyId, title: "Launch parent", status: "done", priority: "medium",
createdByUserId: userId, assigneeAgentId: agentAId,
createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") },
{ id: childId, companyId, parentId, title: "Launch child", status: "done", priority: "medium",
createdByAgentId: agentAId, assigneeAgentId: agentBId,
createdAt: new Date("2026-03-03T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") },
]);
const target = changedIssue === "parent" ? agentAId : agentBId;
const previous = changedIssue === "parent" ? agentBId : agentAId;
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId,
entityType: "issue", entityId: changedIssue === "parent" ? parentId : childId,
action: "issue.updated", createdAt: new Date(at),
details: { assigneeAgentId: target, changes: { assigneeAgentId: { from: previous, to: target } } } });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ limit: 1, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.pagination).toMatchObject({ totalIssues: 2, hasMore: true });
expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "delegation")).toEqual([]);
expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]);
if (changedIssue === "child" && at.startsWith("2026-03-05")) {
expect(response.body.events).toContainEqual({ actorId: `user:${userId}`, issueId: childId, kind: "assigned", at: new Date(at).toISOString() });
expect(response.body.edges).toContainEqual({ fromActorId: `user:${userId}`, toActorId: `agent:${agentBId}`, issueId: childId, kind: "assignment", at: new Date(at).toISOString() });
}
});
it("reads creation and assignment receipts only inside the bounded readable page window", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const parentId = randomUUID(), childId = randomUUID(), hiddenId = randomUUID(), unpagedId = randomUUID();
const foreignCompanyId = randomUUID();
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: "TF" });
await db.insert(issues).values([parentId, unpagedId, childId, hiddenId].map((id, index) => ({
id, companyId, title: `Scoped ${index}`, status: "done", assigneeAgentId: id === parentId ? agentAId : agentBId,
parentId: id === childId ? parentId : null, createdByUserId: userId,
createdAt: new Date(Date.parse("2026-03-01T10:00:00Z") + index * 1000), updatedAt: new Date("2026-03-05T10:00:00Z"),
})));
const ownershipRow = (entityId: string, scopedCompanyId = companyId) => ({ companyId: scopedCompanyId,
actorType: "user", actorId: userId, entityType: "issue", entityId, action: "issue.updated",
createdAt: new Date("2026-03-02T10:00:00Z"), details: { changes: { assigneeAgentId: { from: agentBId, to: agentAId } } } });
await db.insert(activityLog).values([ownershipRow(parentId), ownershipRow(hiddenId), ownershipRow(childId, foreignCompanyId),
...Array.from({ length: 1000 }, () => ownershipRow(unpagedId))]);
const queries: Array<{ query: string; parameters: unknown[] }> = [];
const previousDebug = db.$client.options.debug;
let result;
db.$client.options.debug = (_connection, query, parameters) => queries.push({ query, parameters });
try {
result = await workTimelineService(db).getTimeline({ companyId, limit: 1,
from: new Date("2026-03-05T00:00:00Z"), to: new Date("2026-03-06T00:00:00Z"),
canReadIssue: async issue => issue.id !== hiddenId });
} finally { db.$client.options.debug = previousDebug; }
expect(queries.some(row => /^select distinct /i.test(row.query) && row.query.includes('"activity_log"'))).toBe(false);
const receipts = queries.filter(row => row.query.includes('from "activity_log"') && row.query.includes('"details"') && !row.query.includes("join"));
expect(receipts).toHaveLength(1);
expect(receipts[0]!.query).toMatch(/"created_at" >=/);
expect(receipts[0]!.query).toMatch(/"created_at" <=/);
const scopedIds = receipts[0]!.parameters.filter(value => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value));
expect(new Set(scopedIds)).toEqual(new Set([companyId, childId]));
expect(result.edges).toEqual([]);
expect(result.events.every(event => event.issueId === childId)).toBe(true);
});
it("fails the route when bounded activity receipts cannot be read instead of inventing ownership", async () => {
const { companyId, agentAId } = await seedBase();
const issueId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "History failure", status: "done", assigneeAgentId: agentAId,
createdAt: new Date("2026-03-05T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") });
const select = db.select.bind(db);
const fault = vi.spyOn(db, "select").mockImplementation(((fields: any) =>
fields?.creationRecorded ? select({ ...fields, creationRecorded: sql`1 / 0` }) : select(fields)) as any);
try {
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ issueId, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
expect(fault).toHaveBeenCalled();
expect(response.status).toBe(500);
expect(response.body).not.toHaveProperty("edges");
} finally { fault.mockRestore(); }
});
it("serves the recorded human reassignment instead of inferring the current owner at creation", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const issueId = randomUUID();
const createdAt = new Date("2026-03-05T10:00:00Z");
const assignedAt = new Date("2026-03-05T11:00:00Z");
await db.insert(issues).values({
id: issueId, companyId, title: "Reassigned launch", identifier: "TL-10",
status: "done", priority: "medium", createdByUserId: userId,
assigneeAgentId: agentBId, createdAt, updatedAt: assignedAt,
});
await db.insert(activityLog).values([
{ companyId, actorType: "user", actorId: userId, action: "issue.updated",
entityType: "issue", entityId: issueId, createdAt: assignedAt,
details: { assigneeAgentId: agentBId, _previous: { assigneeAgentId: agentAId } } },
{ companyId, actorType: "agent", actorId: agentBId, action: "issue.updated",
entityType: "issue", entityId: issueId, createdAt: new Date("2026-03-05T12:00:00Z"),
details: { status: "done", _previous: { status: "in_progress" } } },
]);
const res = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([
{ actorId: `user:${userId}`, kind: "assigned", issueId, at: assignedAt.toISOString() },
]);
expect(res.body.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([
{ fromActorId: `user:${userId}`, toActorId: `agent:${agentBId}`, issueId, at: assignedAt.toISOString(), kind: "assignment" },
]);
expect(res.body.actors).toEqual(expect.arrayContaining([
expect.objectContaining({ id: `user:${userId}`, type: "user", name: "User One" }),
expect.objectContaining({ id: `agent:${agentBId}`, type: "agent", name: "QA" }),
]));
});
it("distinguishes absent, null and unchanged owners and keeps same-actor assignment history", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const createdAt = new Date("2026-03-05T10:00:00Z");
const changedAt = new Date("2026-03-05T11:00:00Z");
for (const kind of ["absent", "null", "unchanged", "canonical-empty", "canonical-unchanged", "self-assignment"] as const) {
const issueId = randomUUID();
const assigneeAgentId = kind === "null" ? null : kind === "self-assignment" ? agentBId : agentAId;
await db.insert(issues).values({ id: issueId, companyId, title: kind, status: "done",
priority: "medium", assigneeAgentId, createdByUserId: userId, createdAt, updatedAt: changedAt });
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId,
action: "issue.created", entityType: "issue", entityId: issueId, createdAt,
details: { parentId: null, assigneeAgentId: agentAId, assigneeUserId: null } });
const details = kind === "absent" ? { status: "done" }
: kind === "canonical-empty" ? { assigneeAgentId, changes: {} }
: kind === "canonical-unchanged" ? { assigneeAgentId, changes: { assigneeAgentId: { from: agentAId, to: agentAId } } }
: { assigneeAgentId, _previous: { assigneeAgentId: agentAId } };
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId,
action: "issue.updated", entityType: "issue", entityId: issueId, details, createdAt: changedAt });
const res = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ issueId, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
const assigned = res.body.events.filter((event: { kind: string }) => event.kind === "assigned");
const assignmentEdges = res.body.edges.filter((edge: { kind: string }) => edge.kind === "assignment");
if (kind === "self-assignment") {
expect(assigned).toEqual([{ actorId: `agent:${agentAId}`, kind: "assigned", issueId, at: changedAt.toISOString() }]);
expect(assignmentEdges).toEqual(expect.arrayContaining([
{ fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "assignment" },
{ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId, at: changedAt.toISOString(), kind: "assignment" },
]));
} else {
expect(assigned).toEqual([]);
expect(assignmentEdges).toHaveLength(1);
expect(assignmentEdges[0]).toMatchObject({ at: createdAt.toISOString(), toActorId: `agent:${agentAId}` });
expect(res.body.events.filter((event: { kind: string }) => event.kind === "unassigned")).toEqual(kind === "null"
? [{ actorId: `agent:${agentAId}`, kind: "unassigned", issueId, at: changedAt.toISOString() }] : []);
}
}
});
it("uses actual PATCH receipts to separate repeated owners from reassignment and unassignment", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, companyId));
const issueId = randomUUID();
const createdAt = new Date(Date.now() - 60_000);
await db.insert(issues).values({ id: issueId, companyId, title: "Producer receipt launch",
status: "todo", priority: "medium", assigneeAgentId: agentAId, createdByUserId: userId, createdAt, updatedAt: createdAt });
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId,
action: "issue.created", entityType: "issue", entityId: issueId, createdAt,
details: { parentId: null, assigneeAgentId: agentAId, assigneeUserId: null } });
const app = createApp(userId, true);
const repeated = await request(app).patch(`/api/issues/${issueId}`).send({ status: "backlog", assigneeAgentId: agentAId });
expect(repeated.status, JSON.stringify(repeated.body)).toBe(200);
const repeatedEvent = (await db.select().from(activityLog)).find(row => row.action === "issue.updated" && row.entityId === issueId)!;
expect(repeatedEvent).toMatchObject({ actorType: "user", actorId: userId });
const details = repeatedEvent.details as Record<string, any>;
expect(details.assigneeAgentId).toBe(agentAId);
expect(Object.keys(details.changes)).toContain("status");
expect(details.changes).not.toHaveProperty("assigneeAgentId");
expect(details._previous).not.toHaveProperty("assigneeAgentId");
const timeline = async () => {
const response = await request(app).get(`/api/companies/${companyId}/timeline`)
.query({ issueId, from: new Date(createdAt.getTime() - 1000).toISOString(), to: new Date().toISOString() });
expect(response.status, JSON.stringify(response.body)).toBe(200);
return response.body;
};
const unchanged = await timeline();
expect(unchanged.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([]);
expect(unchanged.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([
{ fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "assignment" },
]);
const reassigned = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: agentBId });
expect(reassigned.status, JSON.stringify(reassigned.body)).toBe(200);
const changed = await timeline();
expect(changed.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([
expect.objectContaining({ actorId: `user:${userId}`, issueId, kind: "assigned" }),
]);
expect(changed.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual(expect.arrayContaining([
{ fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "assignment" },
expect.objectContaining({ fromActorId: `user:${userId}`, toActorId: `agent:${agentBId}`, issueId }),
]));
const unassigned = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: null });
expect(unassigned.status, JSON.stringify(unassigned.body)).toBe(200);
const cleared = await timeline();
const removal = (await db.select().from(activityLog)).find(row => row.entityId === issueId
&& (row.details as any)?.changes?.assigneeAgentId?.to === null)!;
expect(cleared.events).toContainEqual({ actorId: `user:${userId}`, kind: "unassigned", issueId, at: removal.createdAt.toISOString() });
const responseSchema = (buildOpenApiSpec() as any).paths["/api/companies/{companyId}/timeline"]
.get.responses["200"].content["application/json"].schema;
const documentedKinds = responseSchema.properties.events.items.properties.kind.enum;
expect(cleared.events.every((event: { kind: string }) => documentedKinds.includes(event.kind))).toBe(true);
expect(cleared.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual(changed.edges.filter((edge: { kind: string }) => edge.kind === "assignment"));
expect(await db.select().from(heartbeatRuns)).toEqual([]);
});
it("serves GET /api/companies/:companyId/timeline", async () => {
const { companyId, agentAId } = await seedBase();
const issueId = randomUUID();
@@ -538,4 +809,179 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
pagination: expect.objectContaining({ totalIssues: 1 }),
}));
});
it("uses immutable creation assignment and delegation receipts despite later ownership", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const parentId = randomUUID(), issueId = randomUUID();
const createdAt = new Date("2026-03-01T11:00:00Z");
await db.insert(issues).values([
{ id: parentId, companyId, title: "Parent", status: "done", createdByUserId: userId, assigneeAgentId: agentBId,
createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-03T10:00:00Z") },
{ id: issueId, companyId, title: "Child", parentId, status: "done", createdByAgentId: agentAId, assigneeAgentId: agentAId,
createdAt, updatedAt: new Date("2026-03-03T10:00:00Z") },
]);
// This is the immutable receipt shape written by the native create_task authority.
await db.insert(activityLog).values([
{ companyId, actorType: "agent", actorId: agentAId, action: "issue.created", entityType: "issue", entityId: issueId,
createdAt, details: { identifier: "TL-child", title: "Child", parentId, assigneeAgentId: agentBId,
assigneeUserId: null, status: "todo", source: "paperclip_runner_protocol" } },
{ companyId, actorType: "user", actorId: userId, action: "issue.updated", entityType: "issue", entityId: issueId,
createdAt: new Date("2026-03-03T10:00:00Z"), details: { changes: { assigneeAgentId: { from: agentBId, to: agentAId } } } },
]);
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ limit: 1, from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.edges).toEqual(expect.arrayContaining([
{ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId, at: createdAt.toISOString(), kind: "assignment" },
{ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, issueId, at: createdAt.toISOString(), kind: "delegation" },
]));
expect(response.body.events).toContainEqual({ actorId: `agent:${agentAId}`, issueId, at: createdAt.toISOString(), kind: "delegated" });
});
it("does not infer creation ownership when immutable assignment metadata is absent", async () => {
const { companyId, userId, agentAId } = await seedBase();
const issueId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Unknown creation owner", status: "done",
createdByUserId: userId, assigneeAgentId: agentAId, createdAt: new Date("2026-03-01T10:00:00Z") });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.edges).toEqual([]);
});
it.each(["missing", "malformed", "ambiguous", "foreign-target", "foreign-actor"] as const)("rejects %s creation assignment metadata", async kind => {
const { companyId, agentAId, agentBId } = await seedBase();
const foreignCompanyId = randomUUID(), foreignAgentId = randomUUID(), issueId = randomUUID();
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: "FC" });
await db.insert(agents).values({ id: foreignAgentId, companyId: foreignCompanyId, name: "Foreign", role: "engineer", adapterType: "codex_local" });
await db.insert(issues).values({ id: issueId, companyId, title: "Invalid creation receipt", status: "done", assigneeAgentId: agentBId,
createdByAgentId: agentAId, createdAt: new Date("2026-03-01T10:00:00Z") });
const details = kind === "missing" ? { parentId: null }
: kind === "malformed" ? { parentId: null, assigneeAgentId: 42, assigneeUserId: null }
: { parentId: null, assigneeAgentId: kind === "foreign-target" ? foreignAgentId : agentBId,
assigneeUserId: kind === "ambiguous" ? "user-1" : null };
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: kind === "foreign-actor" ? foreignAgentId : agentAId,
action: "issue.created", entityType: "issue", entityId: issueId, details, createdAt: new Date("2026-03-01T10:00:00Z") });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.edges).toEqual([]);
});
it("keeps explicit user creation targets without allowing fallback target fields to override them", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const issueId = randomUUID(), createdAt = new Date("2026-03-01T10:00:00Z");
await db.insert(issues).values({ id: issueId, companyId, title: "User creation owner", status: "done", createdByAgentId: agentAId, createdAt });
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.created",
entityType: "issue", entityId: issueId, createdAt,
details: { parentId: null, assigneeAgentId: null, assigneeUserId: userId, toAgentId: agentBId } });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.edges).toEqual([{ fromActorId: `agent:${agentAId}`, toActorId: `user:${userId}`,
issueId, at: createdAt.toISOString(), kind: "assignment" }]);
});
it.each([{}, { assigneeAgentId: 42 }, { changes: { assigneeAgentId: { from: "old", to: 42 } } }])("does not label missing or malformed assignment targets as unassignment: %j", async details => {
const { companyId, userId } = await seedBase();
const issueId = randomUUID(), createdAt = new Date("2026-03-01T10:00:00Z");
await db.insert(issues).values({ id: issueId, companyId, title: "Unknown assignment target", status: "done", createdAt });
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, action: "issue.assignment",
entityType: "issue", entityId: issueId, createdAt, details });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.events.filter((event: { kind: string }) => event.kind === "unassigned")).toEqual([]);
expect(response.body.edges).toEqual([]);
});
it.each(["unreadable-parent", "foreign-parent", "outside-window", "missing-parent", "malformed-parent"] as const)("does not fabricate delegation from %s creation evidence", async kind => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const issueId = randomUUID(), parentId = randomUUID(), foreignCompanyId = randomUUID();
const createdAt = new Date("2026-03-01T11:00:00Z");
await db.insert(companies).values({ id: foreignCompanyId, name: "Other", issuePrefix: "OP" });
await db.insert(issues).values([
{ id: parentId, companyId: kind === "foreign-parent" ? foreignCompanyId : companyId,
title: "Parent", status: "done", createdByUserId: userId, createdAt: new Date("2026-03-01T10:00:00Z") },
{ id: issueId, companyId, title: "Child", parentId, status: "done", createdByAgentId: agentAId, assigneeAgentId: agentBId,
createdAt, updatedAt: new Date("2026-03-05T10:00:00Z") },
]);
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId,
action: "issue.created", entityType: "issue", entityId: issueId, createdAt,
details: { ...(kind === "missing-parent" ? {} : { parentId: kind === "malformed-parent" ? 42 : parentId }),
assigneeAgentId: agentBId, assigneeUserId: null, source: "paperclip_runner_protocol" } });
const result = await workTimelineService(db).getTimeline({ companyId,
from: new Date(kind === "outside-window" ? "2026-03-05T00:00:00Z" : "2026-03-01T00:00:00Z"),
to: new Date(kind === "outside-window" ? "2026-03-06T00:00:00Z" : "2026-03-02T00:00:00Z"),
canReadIssue: async issue => kind !== "unreadable-parent" || issue.id !== parentId });
expect(result.edges.filter(edge => edge.kind === "delegation")).toEqual([]);
expect(result.events.filter(event => event.kind === "delegated")).toEqual([]);
expect(result.edges.filter(edge => edge.kind === "assignment")).toHaveLength(kind === "outside-window" ? 0 : 1);
});
it.each([["agent", "alias"], ["agent", "primary"], ["user", "alias"], ["user", "primary"]] as const)("retains explicit legacy %s %s clears", async (owner, field) => {
const { companyId, userId, agentAId } = await seedBase();
const issueId = randomUUID(), at = new Date("2026-03-01T11:00:00Z");
await db.insert(issues).values({ id: issueId, companyId, title: "Legacy clear", status: "done", createdAt: at });
const primary = owner === "agent" ? "assigneeAgentId" : "assigneeUserId";
const alias = owner === "agent" ? "toAgentId" : "toUserId";
const target = owner === "agent" ? agentAId : userId;
const details = field === "alias" ? { [alias]: null } : { [primary]: null, [alias]: target };
await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, action: "issue.assigned",
entityType: "issue", entityId: issueId, createdAt: at, details });
const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.events.filter((event: { kind: string }) => event.kind !== "created")).toEqual([
{ actorId: `user:${userId}`, kind: "unassigned", issueId, at: at.toISOString() },
]);
expect(response.body.edges).toEqual([]);
});
it("uses indexed UUID lookups for creation agent identities without unsafe casts", async () => {
const { companyId, agentAId, agentBId } = await seedBase();
await db.insert(agents).values(Array.from({ length: 1000 }, () => ({ companyId, name: "Other agent", role: "engineer", adapterType: "codex_local" })));
await db.execute(sql`analyze agents`);
const issueId = randomUUID(), at = new Date("2026-03-01T11:00:00Z");
await db.insert(issues).values({ id: issueId, companyId, title: "Indexed creation", status: "done", createdAt: at });
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.created",
entityType: "issue", entityId: issueId, createdAt: at,
details: { parentId: null, assigneeAgentId: agentBId, assigneeUserId: null } });
const queries: { query: string; parameters: unknown[] }[] = [];
const previousDebug = db.$client.options.debug;
db.$client.options.debug = (_connection, query, parameters) => queries.push({ query, parameters });
try {
await workTimelineService(db).getTimeline({ companyId, from: new Date("2026-03-01T00:00:00Z"), to: new Date("2026-03-02T00:00:00Z") });
} finally { db.$client.options.debug = previousDebug; }
const receipt = queries.find(row => row.query.includes('from "activity_log"') && row.query.includes('"details"') && !row.query.includes("join"))!;
const explain = await db.$client.unsafe(`explain (analyze, format json) ${receipt.query}`, receipt.parameters as any[]);
const nodes = (plan: any): any[] => [plan, ...(plan.Plans ?? []).flatMap(nodes)];
const scans = nodes(explain[0]!["QUERY PLAN"][0].Plan).filter(node => node["Relation Name"] === "agents");
expect(scans.length).toBeGreaterThan(0);
expect(scans.every(node => /Index/.test(node["Node Type"]) && /\bid\b\s*=/.test(node["Index Cond"] ?? ""))).toBe(true);
expect(scans.some(node => node["Actual Loops"] > 0)).toBe(true);
console.info("Creation agent identity plan", JSON.stringify(scans.map(node => ({ type: node["Node Type"], index: node["Index Name"], condition: node["Index Cond"], loops: node["Actual Loops"], rows: node["Actual Rows"] }))));
});
it("preserves the initial owner from the real ordinary REST create receipt without parent metadata", async () => {
const { companyId, userId, agentAId } = await seedBase();
await db.update(agents).set({ status: "paused" }).where(eq(agents.companyId, companyId));
const app = createApp(userId, true);
const created = await request(app).post(`/api/companies/${companyId}/issues`)
.send({ title: "Ordinary created owner", status: "backlog", assigneeAgentId: agentAId });
expect(created.status, JSON.stringify(created.body)).toBe(201);
const issueId = created.body.id;
const receipt = (await db.select().from(activityLog)).find(row => row.action === "issue.created" && row.entityId === issueId)!;
expect(receipt.details).toMatchObject({ assigneeAgentId: agentAId, assigneeUserId: null });
expect(receipt.details).not.toHaveProperty("parentId");
const response = await request(app).get(`/api/companies/${companyId}/timeline`).query({ issueId,
from: new Date(receipt.createdAt.getTime() - 1000).toISOString(), to: new Date().toISOString() });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.edges).toEqual([{ fromActorId: `user:${userId}`, toActorId: `agent:${agentAId}`,
issueId, at: receipt.createdAt.toISOString(), kind: "assignment" }]);
expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]);
expect(await db.select().from(heartbeatRuns)).toEqual([]);
});
registerTimelineReceiptBoundaryCases({ getDb: () => db, seedBase, createApp });
});
+3
View File
@@ -25,6 +25,7 @@ import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssu
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
import {
childCreatedByRequest,
loadCreationSourceAgent,
loadRunBoundIssue,
resolveIssueCreationSource,
@@ -13210,6 +13211,7 @@ export function issueRoutes(
entityId: issue.id,
details: {
parentId: parent.id,
createdByRequest: childCreatedByRequest(issueId, issue.id),
identifier: issue.identifier,
title: issue.title,
...buildCreateIssueActivityStatusDetails(issue, res),
@@ -13490,6 +13492,7 @@ export function issueRoutes(
entityId: issue.id,
details: {
parentId: sourceIssue.id,
createdByRequest: true,
identifier: issue.identifier,
title: issue.title,
inheritedExecutionWorkspaceFromIssueId: sourceIssue.id,
+1
View File
@@ -1248,6 +1248,7 @@ const workTimelineResponseSchema = z
"approved",
"delegated",
"assigned",
"unassigned",
]),
issueId: z.string(),
at: z.string(),
@@ -17,6 +17,11 @@ export interface IssueCreationSourceRecord {
const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"];
/** The route supplies a fresh UUID; a different returned issue is reused. */
export function childCreatedByRequest(proposedIssueId: string, returnedIssueId: string) {
return proposedIssueId === returnedIssueId;
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
+6 -1
View File
@@ -10003,11 +10003,16 @@ export function issueService(db: Db) {
);
}
let childReused = false;
const createdChild = await issueService(
tx as unknown as Db,
).createChild(sourceIssue.id, {
...nextChildInput,
executionWorkspaceInheritanceMode: "strategy_only",
onDeduplicated: reason => {
childReused = true;
nextChildInput.onDeduplicated?.(reason);
},
});
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
const now = new Date();
@@ -10037,7 +10042,7 @@ export function issueService(db: Db) {
);
return {
claim: updatedClaim,
createdIssue: createdChild.issue,
createdIssue: childReused ? null : createdChild.issue,
};
});
+111 -70
View File
@@ -1,11 +1,12 @@
import { withAgentAppearance } from "@paperclipai/shared";
import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
approvals,
authUsers,
companyMemberships,
heartbeatRuns,
issueApprovals,
issueComments,
@@ -119,8 +120,61 @@ function dateIso(value: Date | null | undefined) {
return value ? value.toISOString() : null;
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
function activeTimelineUser(companyId: string | SQL, userId: SQL) {
return sql<boolean>`exists (select 1 from ${companyMemberships}
where ${companyMemberships.companyId} = ${companyId}
and ${companyMemberships.principalType} = 'user'
and ${companyMemberships.principalId} = ${userId}
and ${companyMemberships.status} = 'active')`;
}
/** Scalar ownership facts for bounded recorded creation and change events. */
function timelineOwnershipProjection() {
const record = (value: SQL) => sql`case when jsonb_typeof(${value}) = 'object' then ${value} else '{}'::jsonb end`;
const details = record(sql`${activityLog.details}`);
const previous = record(sql`${details} -> '_previous'`);
const changes = record(sql`${details} -> 'changes'`);
const canonical = sql`${activityLog.action} = 'issue.updated' and jsonb_typeof(${details} -> 'changes') = 'object'`;
const changed = ["assigneeAgentId", "assigneeUserId"].map(key => {
const change = record(sql`${changes} -> ${key}`);
return sql`case when ${canonical} then
(${change} ? 'from' and ${change} ? 'to' and (${change} -> 'from') is distinct from (${change} -> 'to'))
else (${details} ? ${key} and (not (${previous} ? ${key}) or (${details} -> ${key}) is distinct from (${previous} -> ${key}))) end`;
});
const stringValue = (value: SQL) => sql<string | null>`case when jsonb_typeof(${value}) = 'string'
then nullif(btrim(${value} #>> '{}'), '') else null end`;
const targetValue = (key: string, legacyKey: string) => sql`case when ${canonical} then ${changes} -> ${key} -> 'to'
when ${details} ? ${key} then ${details} -> ${key} else ${details} -> ${legacyKey} end`;
const target = (key: string, legacyKey: string) => stringValue(targetValue(key, legacyKey));
const creationAgentId = stringValue(sql`${details} -> 'assigneeAgentId'`);
const creationUserId = stringValue(sql`${details} -> 'assigneeUserId'`);
const sameCompanyAgent = (id: SQL) => sql`exists (select 1 from ${agents}
where ${agents.id} = case when ${id} ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
then ${id}::uuid else null end and ${agents.companyId} = ${activityLog.companyId})`;
const knownUser = (id: SQL) => sql`(exists (select 1 from ${authUsers} where ${authUsers.id} = ${id})
and ${activeTimelineUser(sql`${activityLog.companyId}`, id)})`;
const creationOwnerTypes = ["assigneeAgentId", "assigneeUserId"].map(key => sql`${details} ? ${key}
and jsonb_typeof(${details} -> ${key}) in ('string', 'null')`);
const cleared = ([["assigneeAgentId", "toAgentId"], ["assigneeUserId", "toUserId"]] as const).map(([key, alias], index) => sql`(
(case when ${canonical} or ${details} ? ${key} then ${changed[index]} else ${details} ? ${alias} end)
and jsonb_typeof(${targetValue(key, alias)}) = 'null')`);
return {
recorded: sql<boolean>`coalesce(position('assign' in ${activityLog.action}) > 0 or
(${activityLog.action} = 'issue.updated' and (${sql.join(changed, sql` or `)})), false)`,
cleared: sql<boolean>`coalesce(${sql.join(cleared, sql` or `)}, false)`,
creationRecorded: sql<boolean>`coalesce((${activityLog.action} = 'issue.created'
or (${activityLog.action} = 'issue.child_created' and ${details} -> 'createdByRequest' = 'true'::jsonb))
and (${sql.join(creationOwnerTypes, sql` and `)})
and ((${creationAgentId} is not null and ${creationUserId} is null and ${sameCompanyAgent(creationAgentId)})
or (${creationUserId} is not null and ${creationAgentId} is null and ${knownUser(creationUserId)}))
and ((${activityLog.actorType} = 'agent' and ${sameCompanyAgent(sql`${activityLog.actorId}`)})
or (${activityLog.actorType} = 'user' and ${knownUser(sql`${activityLog.actorId}`)})), false)`,
creationAgentId,
creationUserId,
creationParentId: stringValue(sql`${details} -> 'parentId'`),
agentId: target("assigneeAgentId", "toAgentId"),
userId: target("assigneeUserId", "toUserId"),
};
}
function readNumber(value: unknown) {
@@ -439,7 +493,7 @@ export function workTimelineService(db: Db) {
? db
.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image })
.from(authUsers)
.where(inArray(authUsers.id, maybeUuidList(userIds)))
.where(and(inArray(authUsers.id, maybeUuidList(userIds)), activeTimelineUser(companyId, sql`${authUsers.id}`)))
: [],
]);
@@ -455,12 +509,6 @@ export function workTimelineService(db: Db) {
return actorId("system", "system");
}
function actorForIssueAssignee(issue: IssueRow) {
if (issue.assigneeAgentId) return actorId("agent", issue.assigneeAgentId);
if (issue.assigneeUserId) return actorId("user", issue.assigneeUserId);
return null;
}
async function getTimeline(input: WorkTimelineQuery): Promise<WorkTimelineResult> {
const { from, to, capped } = normalizeTimelineWindow(input);
const limit = normalizeLimit(input.limit);
@@ -486,6 +534,7 @@ export function workTimelineService(db: Db) {
};
}
const ownership = timelineOwnershipProjection();
const actorIds = new Set<string>();
const events: WorkTimelineEvent[] = [];
const edges: WorkTimelineEdge[] = [];
@@ -499,37 +548,6 @@ export function workTimelineService(db: Db) {
issueId: issue.id,
at: issue.createdAt.toISOString(),
});
const assigneeActorId = actorForIssueAssignee(issue);
if (assigneeActorId) {
actorIds.add(assigneeActorId);
edges.push({
fromActorId: creatorActorId,
toActorId: assigneeActorId,
issueId: issue.id,
at: issue.createdAt.toISOString(),
kind: "assignment",
});
}
const parent = issue.parentId ? issueById.get(issue.parentId) : null;
const parentActorId = parent ? actorForIssueAssignee(parent) ?? actorForIssueCreator(parent) : null;
if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId) {
actorIds.add(parentActorId);
edges.push({
fromActorId: parentActorId,
toActorId: assigneeActorId,
issueId: issue.id,
at: issue.createdAt.toISOString(),
kind: "delegation",
});
events.push({
actorId: parentActorId,
kind: "delegated",
issueId: issue.id,
at: issue.createdAt.toISOString(),
});
}
}
const [contextRunRows, activityRunRows, commentRows, approvalRows, interactionRows, logRows] = await Promise.all([
@@ -645,7 +663,14 @@ export function workTimelineService(db: Db) {
actorType: activityLog.actorType,
actorId: activityLog.actorId,
action: activityLog.action,
details: activityLog.details,
creationRecorded: ownership.creationRecorded,
creationParentId: ownership.creationParentId,
creationAgentId: ownership.creationAgentId,
creationUserId: ownership.creationUserId,
assignmentCleared: ownership.cleared,
assignmentRecorded: ownership.recorded,
assignmentAgentId: ownership.agentId,
assignmentUserId: ownership.userId,
createdAt: activityLog.createdAt,
})
.from(activityLog)
@@ -728,39 +753,55 @@ export function workTimelineService(db: Db) {
});
}
// The first visible receipt is selected before validating its facts. A
// later replay must not rescue an unknown original or invent its owner.
const creationReceipts = new Map<string, typeof logRows[number]>();
for (const row of logRows) {
if (row.action !== "issue.created" && row.action !== "issue.child_created") continue;
const first = creationReceipts.get(row.issueId);
if (!first || row.createdAt < first.createdAt) creationReceipts.set(row.issueId, row);
}
for (const row of logRows) {
const logActorType = row.actorType === "agent" || row.actorType === "user" || row.actorType === "plugin"
? row.actorType
: "system";
? row.actorType : "system";
const fromActorId = actorId(logActorType, row.actorId);
actorIds.add(fromActorId);
if (row.action.includes("assign")) {
events.push({ actorId: fromActorId, kind: "assigned", issueId: row.issueId, at: row.createdAt.toISOString() });
const details = row.details && typeof row.details === "object" && !Array.isArray(row.details)
? row.details as Record<string, unknown>
: {};
const targetAgentId = readString(details.assigneeAgentId) ?? readString(details.toAgentId);
const targetUserId = readString(details.assigneeUserId) ?? readString(details.toUserId);
const toActorId = targetAgentId
? actorId("agent", targetAgentId)
: targetUserId
? actorId("user", targetUserId)
: null;
if (toActorId) {
actorIds.add(toActorId);
edges.push({
fromActorId,
toActorId,
issueId: row.issueId,
at: row.createdAt.toISOString(),
kind: "assignment",
});
if (row.action === "issue.created" || row.action === "issue.child_created") {
const issue = issueById.get(row.issueId);
if (creationReceipts.get(row.issueId) !== row || !row.creationRecorded || !issue
|| issue.createdAt < from || issue.createdAt > to) continue;
const toActorId = row.creationAgentId ? actorId("agent", row.creationAgentId)
: actorId("user", row.creationUserId!);
actorIds.add(fromActorId);
actorIds.add(toActorId);
edges.push({ fromActorId, toActorId, issueId: row.issueId, at: row.createdAt.toISOString(), kind: "assignment" });
// Delegation is the recorded creating agent's action under a readable
// parent, not an inference from that parent's mutable current owner.
if (logActorType === "agent" && row.creationParentId
&& row.creationParentId !== row.issueId && issueById.has(row.creationParentId) && fromActorId !== toActorId) {
edges.push({ fromActorId, toActorId, issueId: row.issueId, at: row.createdAt.toISOString(), kind: "delegation" });
events.push({ actorId: fromActorId, kind: "delegated", issueId: row.issueId, at: row.createdAt.toISOString() });
}
continue;
}
if (!row.assignmentRecorded) continue;
const toActorId = row.assignmentAgentId ? actorId("agent", row.assignmentAgentId)
: row.assignmentUserId ? actorId("user", row.assignmentUserId) : null;
if (!toActorId && !row.assignmentCleared) continue;
actorIds.add(fromActorId);
events.push({ actorId: fromActorId, kind: toActorId ? "assigned" : "unassigned", issueId: row.issueId, at: row.createdAt.toISOString() });
if (!toActorId) continue;
actorIds.add(toActorId);
edges.push({ fromActorId, toActorId, issueId: row.issueId, at: row.createdAt.toISOString(), kind: "assignment" });
}
const actorMaps = await loadActorMaps(input.companyId, actorIds);
const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => {
const publicActorIds = new Set(Array.from(actorIds).filter(id => {
if (id.startsWith("agent:")) return actorMaps.agents.has(id.slice("agent:".length));
if (id.startsWith("user:")) return actorMaps.users.has(id.slice("user:".length));
return true;
}));
const actors: WorkTimelineActor[] = Array.from(publicActorIds).map((id) => {
const [type, rawId] = id.split(":", 2) as [TimelineActorType, string];
if (type === "agent") {
const agent = actorMaps.agents.get(rawId);
@@ -776,9 +817,9 @@ export function workTimelineService(db: Db) {
return {
actors,
spans: Array.from(spanByRunId.values()).sort((left, right) => left.start.localeCompare(right.start)),
events: events.sort((left, right) => left.at.localeCompare(right.at)),
edges: edges.sort((left, right) => left.at.localeCompare(right.at)),
spans: Array.from(spanByRunId.values()).filter(span => publicActorIds.has(span.actorId)).sort((left, right) => left.start.localeCompare(right.start)),
events: events.filter(event => publicActorIds.has(event.actorId)).sort((left, right) => left.at.localeCompare(right.at)),
edges: edges.filter(edge => publicActorIds.has(edge.fromActorId) && publicActorIds.has(edge.toActorId)).sort((left, right) => left.at.localeCompare(right.at)),
pagination: {
limit,
offset,
@@ -1,6 +1,6 @@
{
"schema": "paperclip.chat-full-catalog-testops-manifest/v1",
"targetSha": "ab2019e976076ec0eb60b026844f3d9211cb0208",
"targetSha": "fd5e3b1e5a379524c0d1651ed8273e75ffd86541",
"metadataSourceSha": "de0941835cc8f0d01c982574ee258e110babdbeb",
"profile": "runner-opencode",
"model": "openrouter/deepseek/deepseek-v4.1-flash-1017",
@@ -170,9 +170,9 @@
],
"repairMetadata": {
"baseTargetSha": "3b66c3c144360ddf32714a0742fd774b8ceafb16",
"previousTargetSha": "de6d29e025683f5c28125bcaa4f38b41836d8516",
"previousTargetSha": "ab2019e976076ec0eb60b026844f3d9211cb0208",
"definitionSourceSha": "de0941835cc8f0d01c982574ee258e110babdbeb",
"candidateDescriptorSha256": "d214f8bf1d6c8c737682999ca9eb9d4ea2a5945bebbccb96e9ac326d538b0629",
"candidateDescriptorSha256": "b70bd45c3e0bb2fcca04788259b7935483dd2eb5c8be82300730511383a2a150",
"changedDefinitionFiles": [
{
"path": "tests/runner-e2e/chat-scenarios/companies.yaml",
@@ -191,7 +191,7 @@
"sha256": "18fbb9419bbdacb46eacdaa62dfa99feb9024faf2d1eef829aa70d43952575a9"
}
],
"conditions": "New candidate measurements only; original definitions, failed attempts and previousSource5 de6 remain immutable. Source6 retains all Source5 repairs and adds remaining ORIGINAL deadline plus exact canonical source guards for personal-secret and team-install initial observations, real hire-review link readiness, and genuine skill-update provider checkpoints after cheap native readiness. Candidate-only Core18742/61996 overlay permits only a new Ask-first review for a strictly authenticated later same-person human intent after decline, including issue.comment.reopen; same-intent replay remains denied, with no approval or provider authority granted. All queries/models/human/native counts/clocks remain as de6; earlier Studio144 explicit2..3 accounting remains. CorePRs18717/18734/18742 remain UNMERGED; SDK115 completion remainsUNKNOWN and otherfiveSDK cases stay held.",
"conditions": "New candidate measurements only. Original definitions, prior failed attempts and source6 ab201 remain historical. This target retains that catalog and integrates merged runtime fixes #18711, #18713 and #18718, with the prior-run restoration condition adapted to its existing owner. It adds the reviewed previous-question locator, attributed human-only denial and conditional-outcome oracle, and actor-sensitive timeline narrative/recorded-reassignment fixes. Closed finish diagnostics retain an effective schema digest, hashed call correlation and bounded type/category enums only. Historical MCP timeout and continuation-shape causes remain unresolved; no new all-ten PASS is claimed. Queries, model, human/native counts and original clocks remain fixed; the earlier Studio144 explicit 2..3 accounting stays. Core PRs18717/18734/18742 are now merged. SDK115 completion remains UNKNOWN and the other five SDK cases remain held. Full244 runtime catalog is not merged to master and the196/244 gate is unchanged.",
"changedFields": [
{
"key": "activity.work-timeline",
@@ -214,14 +214,22 @@
"chat-catalog-domains/internal-activity.ts",
"chat-scenarios/activity.yaml",
"chat-catalog-domains/internal-work-timeline.ts",
"chat-catalog-domains/internal-timeline-narrative.ts",
"chat-catalog-domains/internal-timeline-verifier.ts",
"chat-catalog-domains/internal-privacy.ts",
"chat-catalog-domains/identity-auth.ts",
"chat-catalog-domains/internal-routine-audit.ts",
"../e2e/fetch-allowed-port.ts",
"../../server/src/services/native-runtime/status-decision-committer.ts"
"../../server/src/services/native-runtime/status-decision-committer.ts",
"../../server/src/services/work-timeline.ts"
]
},
{
"key": "activity.work-timeline",
"field": "oracleVersion",
"previous": "paperclip.chat-catalog.internal.v1",
"current": "paperclip.chat-catalog.work-timeline.v2"
},
{
"key": "agents.choose-primary",
"field": "sourceFiles",
@@ -261,6 +269,9 @@
],
"current": [
"chat-catalog-domains/external-app-review.ts",
"chat-catalog-domains/external-app-review-outcomes.ts",
"chat-catalog-domains/external-app-review-probe.ts",
"../../packages/shared/src/validators/issue.ts",
"chat-catalog-domains/external-provider.ts",
"chat-catalog-domains/identity-support.ts",
"chat-catalog-domains/external-secret-scan.ts",
@@ -286,7 +297,7 @@
"key": "app-permissions.ask-before-action",
"field": "oracleVersion",
"previous": "paperclip.chat-catalog.external.v1",
"current": "paperclip.chat-catalog.app-review.v2"
"current": "paperclip.chat-catalog.app-review.v3"
},
{
"key": "app-permissions.test-as-agent",
@@ -842,14 +853,22 @@
"chat-catalog-domains/internal-activity.ts",
"chat-scenarios/activity.yaml",
"chat-catalog-domains/internal-work-timeline.ts",
"chat-catalog-domains/internal-timeline-narrative.ts",
"chat-catalog-domains/internal-timeline-verifier.ts",
"chat-catalog-domains/internal-privacy.ts",
"chat-catalog-domains/identity-auth.ts",
"chat-catalog-domains/internal-routine-audit.ts",
"../e2e/fetch-allowed-port.ts",
"../../server/src/services/native-runtime/status-decision-committer.ts"
"../../server/src/services/native-runtime/status-decision-committer.ts",
"../../server/src/services/work-timeline.ts"
]
},
{
"key": "activity.work-timeline",
"field": "oracleVersion",
"previous": "paperclip.chat-catalog.internal.v1",
"current": "paperclip.chat-catalog.work-timeline.v2"
},
{
"key": "agents.choose-primary",
"field": "sourceFiles",
@@ -889,6 +908,9 @@
],
"current": [
"chat-catalog-domains/external-app-review.ts",
"chat-catalog-domains/external-app-review-outcomes.ts",
"chat-catalog-domains/external-app-review-probe.ts",
"../../packages/shared/src/validators/issue.ts",
"chat-catalog-domains/external-provider.ts",
"chat-catalog-domains/identity-support.ts",
"chat-catalog-domains/external-secret-scan.ts",
@@ -914,7 +936,7 @@
"key": "app-permissions.ask-before-action",
"field": "oracleVersion",
"previous": "paperclip.chat-catalog.external.v1",
"current": "paperclip.chat-catalog.app-review.v2"
"current": "paperclip.chat-catalog.app-review.v3"
},
{
"key": "app-permissions.test-as-agent",
@@ -1481,11 +1503,11 @@
"runtimeConditions": {
"toolGuidance106": {
"corePR": 18717,
"corePRUnmerged": true,
"corePRUnmerged": false,
"sourcePath": "packages/paperclip-runner/src/protocol-actions/create-skill.ts",
"sourceSha256": "02738a3afec54d7d5f417c10efd96f428409da4748d58d9bd60c2c4a6457916e",
"queryAndModelUnchanged": true,
"condition": "Candidate-only new-authoring versus existing-source-import guidance; own catalogs regenerated, no master inventory transplant."
"condition": "Reviewed new-authoring versus existing-source-import guidance; source PR18717 is merged. Own catalog source bytes remain pinned without a master catalog transplant."
},
"studio144Accounting": {
"key": "skills.studio-test",
@@ -1505,8 +1527,8 @@
"condition": "Reviewed synthetic staging input only; per-worker singleton550 enables it; production feed and global index unchanged."
},
"sdkPublicProof": {
"previousRegistrySha256": "018b5b15450e84f63920575de3b5903f7838678ef66a1e17aa050e0b21b40319",
"registrySha256": "32c7971e1d9c9d31cd6791045b0265f0475c8f10932ff6dd34681681b6a89342",
"previousRegistrySha256": "32c7971e1d9c9d31cd6791045b0265f0475c8f10932ff6dd34681681b6a89342",
"registrySha256": "9169583e54d165dc8d08b9a9010175d11ca87f3590477a7b23032508020ea039",
"inputCount": 506,
"scannerAndCanaryCertificatesUnchanged": true,
"plugin558StillHeld": true,
@@ -1514,17 +1536,37 @@
"previousExpectedLockSha256": "c7f23021335b60da0cff1dd4c75cf8b948333ef3165d7d1703eb79a589edb798",
"expectedLockSha256": "54a81b2ed3f1f85eaa085c034560ac248e384db34fbc7aba8c5a3846c29b3ce8",
"historical43Finding": "detector3 unresolved_context/publicSource null; owning proof rejected stale expected lock",
"newCandidate115Unqualified": true
"newCandidate115Unqualified": true,
"sourceInputsChanged": true,
"changedInputPaths": [
"packages/shared/src/types/work-timeline.ts"
],
"compileQualification": {
"baselineSourceSha": "124232037eebe73a513907b08f0e96bfdd454c82",
"compiledSourceSha": "6abe5739a713d0a8bb0308b1495846881b82d253",
"acceptedSourceSha": "b734393eff0d7ef85b7ad8abd556d2a66ca3a4e6",
"all506InputGitBlobsMatched": true,
"owningSharedAndPluginSdkTypeScriptBuildsPassed": true,
"changedRuntimeJavaScriptFiles": 0,
"changedDeclarationFiles": [
"packages/shared/dist/types/work-timeline.d.ts"
],
"existingAndUnassignedConsumerCompiled": true,
"unknownEventKindRejected": true,
"sourceSdkControlsPassed": 51,
"inputAndBuildCustodyControlsPassed": 20,
"runtimeHeldCasesRemainHeld": true
}
},
"toolProposal487": {
"corePr": 18734,
"coreHead": "50600786f755b6602989f3b0f88cd090ff948196",
"corePRUnmerged": true,
"condition": "Candidate-only reviewed disclosure plus bounded pre-trim guard; no authority change. CorePR18734 remainsunmerged.",
"corePRUnmerged": false,
"condition": "Reviewed disclosure and bounded pre-trim guard from merged CorePR18734; no authority change.",
"replay": {
"corePR": 18742,
"coreHead": "61996a96dd09e90a5788e8711a94fc5ae631f425",
"corePRUnmerged": true,
"corePRUnmerged": false,
"condition": "A new Ask-first request only for an authenticated later same-person human intent after the prior decline; same-intent retry and derived or foreign sources remain denied. No approval/execution authority change.",
"acceptedDirectSources": [
"issue.comment",
@@ -1536,7 +1578,7 @@
},
"candidateLauncherSnapshot": {
"path": "fixtures/chat-repair-candidate-launcher.txt",
"targetSha": "ab2019e976076ec0eb60b026844f3d9211cb0208",
"targetSha": "fd5e3b1e5a379524c0d1651ed8273e75ffd86541",
"sourcePath": "tests/runner-e2e/launch.ts",
"gitBlobOid": "dba1467e211611f37937e9e1fc4c0c5be4329cbc"
}
@@ -2079,19 +2121,21 @@
"activity/timeline"
],
"checks": null,
"oracleVersion": "paperclip.chat-catalog.internal.v1",
"oracleVersion": "paperclip.chat-catalog.work-timeline.v2",
"sourceFiles": [
"chat-catalog-domains/internal-definition.ts",
"chat-catalog-domains/internal-support.ts",
"chat-catalog-domains/internal-activity.ts",
"chat-scenarios/activity.yaml",
"chat-catalog-domains/internal-work-timeline.ts",
"chat-catalog-domains/internal-timeline-narrative.ts",
"chat-catalog-domains/internal-timeline-verifier.ts",
"chat-catalog-domains/internal-privacy.ts",
"chat-catalog-domains/identity-auth.ts",
"chat-catalog-domains/internal-routine-audit.ts",
"../e2e/fetch-allowed-port.ts",
"../../server/src/services/native-runtime/status-decision-committer.ts"
"../../server/src/services/native-runtime/status-decision-committer.ts",
"../../server/src/services/work-timeline.ts"
],
"screenshot": "final-state.png",
"evidenceKind": "private-final-state-png",
@@ -3155,9 +3199,12 @@
"questions-and-approvals/tool-review"
],
"checks": null,
"oracleVersion": "paperclip.chat-catalog.app-review.v2",
"oracleVersion": "paperclip.chat-catalog.app-review.v3",
"sourceFiles": [
"chat-catalog-domains/external-app-review.ts",
"chat-catalog-domains/external-app-review-outcomes.ts",
"chat-catalog-domains/external-app-review-probe.ts",
"../../packages/shared/src/validators/issue.ts",
"chat-catalog-domains/external-provider.ts",
"chat-catalog-domains/identity-support.ts",
"chat-catalog-domains/external-secret-scan.ts",
@@ -102,8 +102,8 @@ test('foreign identity, duplicated case and changed run-bound metadata are rejec
test('repair source metadata binds the corrected candidate and explicit changed definitions', () => {
const repair = manifest.source.repairMetadata;
assert.equal(repair.baseTargetSha, '3b66c3c144360ddf32714a0742fd774b8ceafb16');
assert.equal(repair.previousTargetSha, 'de6d29e025683f5c28125bcaa4f38b41836d8516');
assert.equal(manifest.targetSha, 'ab2019e976076ec0eb60b026844f3d9211cb0208');
assert.equal(repair.previousTargetSha, 'ab2019e976076ec0eb60b026844f3d9211cb0208');
assert.equal(manifest.targetSha, 'fd5e3b1e5a379524c0d1651ed8273e75ffd86541');
assert.equal(repair.definitionSourceSha, manifest.metadataSourceSha);
assert.equal(repair.candidateDescriptorSnapshot, 'fixtures/chat-repair-candidate-descriptors.json');
const descriptorBytes = readFileSync(new URL(repair.candidateDescriptorSnapshot, import.meta.url));
@@ -116,6 +116,10 @@ test('repair source metadata binds the corrected candidate and explicit changed
"activity.work-timeline",
"sourceFiles"
],
[
"activity.work-timeline",
"oracleVersion"
],
[
"agents.choose-primary",
"sourceFiles"
@@ -285,7 +289,7 @@ test('repair source metadata binds the corrected candidate and explicit changed
assert.deepEqual(row(change.key)[change.field], change.current);
assert.notDeepEqual(change.current, change.previous);
}
assert.equal(repair.descriptorChanges.length, 41);
assert.equal(repair.descriptorChanges.length, 42);
assert.equal(new Set(repair.descriptorChanges.map(change => change.key)).size, 28);
assert.deepEqual([...new Set(repair.descriptorChanges.map(change => change.field))].sort(), ['expectedRunCount', 'minimumRunCount', 'oracleVersion', 'setupPolicy', 'sourceFiles']);
for (const change of repair.descriptorChanges) {
@@ -310,16 +314,18 @@ test('repair source metadata binds the corrected candidate and explicit changed
}
assert.deepEqual(repair.changedFields.filter(change => ['minimumRuns', 'maximumRuns'].includes(change.field)), [{ key: 'skills.studio-test', field: 'maximumRuns', previous: 2, current: 3 }]);
assert.deepEqual(repair.runtimeConditions.studio144Accounting, { key: 'skills.studio-test', previousMinimumRuns: 2, previousMaximumRuns: 2, minimumRuns: 2, maximumRuns: 3, requiredActualAcceptedRuns: 3, originalAttemptTimeoutMs: 540000, humanMessages: 1, condition: 'Initial CEO + real Studio worker + exactly one source-bound completion delivery;2-run no-delivery positive is rejected.' });
assert.equal(repair.runtimeConditions.toolGuidance106.corePRUnmerged, true);
assert.equal(repair.runtimeConditions.toolGuidance106.corePRUnmerged, false);
assert.equal(repair.runtimeConditions.sdkPublicProof.scannerAndCanaryCertificatesUnchanged, true);
assert.equal(repair.runtimeConditions.sdkPublicProof.sourceInputsChanged, true);
assert.equal(repair.runtimeConditions.sdkPublicProof.registrySha256, "9169583e54d165dc8d08b9a9010175d11ca87f3590477a7b23032508020ea039");
assert.equal(repair.runtimeConditions.sdkPublicProof.plugin558StillHeld, true);
assert.equal(repair.runtimeConditions.sdkPublicProof.expectedLockSha256, "54a81b2ed3f1f85eaa085c034560ac248e384db34fbc7aba8c5a3846c29b3ce8");
assert.equal(repair.runtimeConditions.sdkPublicProof.runtimeCompilerParserZodIdentitiesUnchanged, true);
assert.equal(repair.runtimeConditions.toolProposal487.corePRUnmerged, true);
assert.equal(repair.runtimeConditions.toolProposal487.corePRUnmerged, false);
assert.deepEqual(repair.runtimeConditions.toolProposal487.replay, {
"corePR": 18742,
"coreHead": "61996a96dd09e90a5788e8711a94fc5ae631f425",
"corePRUnmerged": true,
"corePRUnmerged": false,
"condition": "A new Ask-first request only for an authenticated later same-person human intent after the prior decline; same-intent retry and derived or foreign sources remain denied. No approval/execution authority change.",
"acceptedDirectSources": [
"issue.comment",
@@ -249,14 +249,16 @@
"chat-catalog-domains/internal-activity.ts",
"chat-scenarios/activity.yaml",
"chat-catalog-domains/internal-work-timeline.ts",
"chat-catalog-domains/internal-timeline-narrative.ts",
"chat-catalog-domains/internal-timeline-verifier.ts",
"chat-catalog-domains/internal-privacy.ts",
"chat-catalog-domains/identity-auth.ts",
"chat-catalog-domains/internal-routine-audit.ts",
"../e2e/fetch-allowed-port.ts",
"../../server/src/services/native-runtime/status-decision-committer.ts"
"../../server/src/services/native-runtime/status-decision-committer.ts",
"../../server/src/services/work-timeline.ts"
],
"oracleVersion": "paperclip.chat-catalog.internal.v1",
"oracleVersion": "paperclip.chat-catalog.work-timeline.v2",
"coverageGaps": [],
"expectedRunCount": 3,
"minimumRunCount": 3,
@@ -804,6 +806,9 @@
"scenarioId": "app-permissions.ask-before-action",
"sourceFiles": [
"chat-catalog-domains/external-app-review.ts",
"chat-catalog-domains/external-app-review-outcomes.ts",
"chat-catalog-domains/external-app-review-probe.ts",
"../../packages/shared/src/validators/issue.ts",
"chat-catalog-domains/external-provider.ts",
"chat-catalog-domains/identity-support.ts",
"chat-catalog-domains/external-secret-scan.ts",
@@ -824,7 +829,7 @@
"../../ui/src/components/task-chat/TaskChatComposer.tsx",
"chat-scenarios/app-permissions.yaml"
],
"oracleVersion": "paperclip.chat-catalog.app-review.v2",
"oracleVersion": "paperclip.chat-catalog.app-review.v3",
"expectedRunCount": 6,
"expectedHumanMessageCount": 3,
"minimumRunCount": 6,