diff --git a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml index e9abf0730..ce3be2cb0 100644 --- a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml +++ b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml @@ -3,6 +3,9 @@ id: api.golden.component-boundaries title: api golden component boundaries scope: local-component-tests workflow: + - id: inbox-question-scope-native + action: { verifier: api.golden.inbox-question-scope-native } + expect: { status: passed } - id: retired-class-profile-unit action: { verifier: api.golden.retired-class-profile-unit } expect: { status: passed } @@ -2577,6 +2580,12 @@ workflow: - id: mcp-write-preconditions-unit action: { verifier: api.golden.mcp-write-preconditions-unit } expect: { status: passed } + - id: mcp-problem-legacy + action: { verifier: api.golden.mcp-problem-legacy } + expect: { status: passed } + - id: mcp-problem-modern + action: { verifier: api.golden.mcp-problem-modern } + expect: { status: passed } - id: mcp-write-preconditions-postgres action: { verifier: api.golden.mcp-write-preconditions-postgres } expect: { status: passed } diff --git a/guarantees/verifiers/golden.verifiers.yaml b/guarantees/verifiers/golden.verifiers.yaml index 95db01242..b3c7b080d 100644 --- a/guarantees/verifiers/golden.verifiers.yaml +++ b/guarantees/verifiers/golden.verifiers.yaml @@ -1,6 +1,11 @@ schemaVersion: treeseed.guarantee-verifiers/v1 ownerPackage: "@treeseed/api" verifiers: + api.golden.inbox-question-scope-native: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-native.test.ts + testName: "real native inbox question authoring grants only its question path and retains exact Git bytes and relations" api.golden.retired-class-profile-unit: kind: vitestCase ownerPackage: "@treeseed/api" @@ -4526,6 +4531,16 @@ verifiers: ownerPackage: "@treeseed/api" testFile: tests/unit/control-plane/mcp/authorization-equivalence.test.ts testName: "retains real MCP mutation identity and version headers and denies missing authority before dispatch" + api.golden.mcp-problem-legacy: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/mcp/authorization-equivalence.test.ts + testName: "preserves the shared safe operation problem over native MCP legacy" + api.golden.mcp-problem-modern: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/mcp/authorization-equivalence.test.ts + testName: "preserves the shared safe operation problem over native MCP 2026-07-28" api.golden.mcp-write-preconditions-postgres: kind: vitestCase ownerPackage: "@treeseed/api" diff --git a/src/api/capacity/policy/execution/execution-graph-projector.ts b/src/api/capacity/policy/execution/execution-graph-projector.ts index 7983066c5..6438a7a10 100644 --- a/src/api/capacity/policy/execution/execution-graph-projector.ts +++ b/src/api/capacity/policy/execution/execution-graph-projector.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { agentProfileReference } from './agent-profile-reference.ts'; import { contributionBindings } from './contribution-bindings.ts'; +import type { QuestionConditionSource } from '../../services/capacity/execution/question-condition-source.ts'; import { executionNodeSchema, graphRevisionSchema, @@ -203,6 +204,7 @@ export function projectTeamExecutionGraph(input: { sources: ExecutableProposalSource[]; profiles: Record; dependencyLinks?: VerifiedDependencyLink[]; + questionSources?: QuestionConditionSource[]; createdAt?: string; }): ExecutionGraphProjection { const nodes: ExecutionNode[] = []; @@ -258,6 +260,22 @@ export function projectTeamExecutionGraph(input: { } else completionByItem.set(text(workItem.id), actor); } + for (const question of input.questionSources ?? []) { + if (question.teamId !== source.teamId || question.projectId !== source.projectId || question.severity !== 'blocking' + || !question.relatedProposalIds.includes(sourceRef.id) + || question.relatedDecisionIds.length && !question.relatedDecisionIds.includes(source.decision?.id ?? '')) continue; + const targets = [...new Set(question.relatedWorkItemIds)].flatMap(id => actorByItem.has(id) ? [actorByItem.get(id)!] : []); + if (!targets.length) continue; + const questionRef: ExactEntityReference = { store: 'treedx', model: 'question', id: question.id, + revision: question.revision, digest: question.digest, repository: question.repositoryId, path: question.path, commit: question.commit }; + const resolved = question.status === 'answered' && question.answerRef !== null; + const condition = feedbackConditionNode(exactSource, { id: `inbox:${question.id}`, kind: 'question', sourceRef: questionRef, resolved }, input.revision); + if (resolved) condition.authorityRefs = [question.answerRef!]; + nodes.push(condition); changedSourceRefs.push(questionRef, ...(resolved ? [question.answerRef!] : [])); + for (const actor of targets) edges.push(edge({ teamId: source.teamId, fromNodeId: condition.id, toNodeId: actor.id, + provenance: 'governance', sourceRef: questionRef }, input.revision)); + } + for (const workItem of workItems) { const actor = actorByItem.get(text(workItem.id)); if (!actor) continue; diff --git a/src/api/capacity/services/capacity/execution/exact-dependency-links.ts b/src/api/capacity/services/capacity/execution/exact-dependency-links.ts index 7c0aae844..03e3722aa 100644 --- a/src/api/capacity/services/capacity/execution/exact-dependency-links.ts +++ b/src/api/capacity/services/capacity/execution/exact-dependency-links.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { exactDependencyLinkSchema, validatePortableContentData } from '@treeseed/sdk/content-validation'; -import { projectLibraryPath, resolveKnowledgeGatewayConnection } from '../../../../knowledge/gateway-treedx-connection.ts'; +import { completedGraphRefresh, projectLibraryPath, resolveKnowledgeGatewayConnection } from '../../../../knowledge/gateway-treedx-connection.ts'; import type { ExecutableProposalSource, VerifiedDependencyLink } from '../../../policy/execution/execution-graph-projector.ts'; type Row = Record; @@ -42,9 +42,19 @@ export async function loadTeamExactDependencyLinks(store: any, sources: Executab projectId: project.projectId, write: false, relationPaths: true, readRefs: [commit], }); if (!pinned || pinned.repositoryId !== project.repository) throw new Error(`TreeDX exact dependency read grant is missing for project ${project.projectId}.`); - const response = record(await pinned.client.queryGraph({ repoId: pinned.repositoryId, + const query = { repoId: pinned.repositoryId, ref: commit, seeds: [{ kind: 'type', value: 'ExactEntityReference' }], - relations: ['depends_on'], options: { direction: 'both', depth: 1, maxNodes: 50 } })); + relations: ['depends_on'], options: { direction: 'both', depth: 1, maxNodes: 50 } }; + let response: Row; + try { response = record(await pinned.client.queryGraph(query)); } + catch (error) { + if (text(record(error).code) !== 'graph_not_ready') throw error; + const refresh = await resolveKnowledgeGatewayConnection(store, { projectId: project.projectId, + write: false, relationPaths: true, readRefs: [commit], refreshGraph: true }); + if (!refresh || refresh.repositoryId !== project.repository) throw new Error(`TreeDX exact dependency refresh grant is missing for project ${project.projectId}.`); + await completedGraphRefresh(refresh.client, { repoId: refresh.repositoryId, ref: commit, paths: refresh.allowedPaths }); + response = record(await pinned.client.queryGraph(query)); + } if (text(response.resolvedRef) !== commit) throw new Error('TreeDX dependency graph moved during read.'); const nodes = response.nodes; if (!Array.isArray(nodes) || nodes.length >= 50 || !Array.isArray(response.edges)) { diff --git a/src/api/capacity/services/capacity/execution/question-condition-source.ts b/src/api/capacity/services/capacity/execution/question-condition-source.ts index abc74db29..5d247d5c4 100644 --- a/src/api/capacity/services/capacity/execution/question-condition-source.ts +++ b/src/api/capacity/services/capacity/execution/question-condition-source.ts @@ -1,3 +1,6 @@ +import { exactEntityReferenceSchema, type ExactEntityReference } from '@treeseed/sdk/agent-capacity'; +import { isDeepStrictEqual } from 'node:util'; +import { inboxQuestionReference } from '../../../../control-plane/inbox/question-content.ts'; type Row = Record; function record(value: unknown): Row { if (value && typeof value === 'object' && !Array.isArray(value)) return value as Row; @@ -24,28 +27,36 @@ export interface QuestionConditionSource { relatedProposalIds: string[]; relatedDecisionIds: string[]; relatedWorkItemIds: string[]; - answerRef: { model: 'discussion_message'; id: string; revision: number; digest: string; repositoryId: string; path: string; commit: string } | null; + answerRef: ExactEntityReference | null; } -/** Read the indexed current question lifecycle while retaining exact TreeDX - * provenance. Question and answer bytes remain in TreeDX. */ +/** Question and answer bytes remain in TreeDX. Only the owning readback's + * existing lifecycle event can prove an answer; author-supplied MDX metadata + * cannot assert verification or reuse proof after scope edits or reopen. */ export async function loadTeamQuestionConditionSources(store: any, teamId: string): Promise { const rows = await store.all(`SELECT id,team_id,project_id,status,title,version,repository_id,content_path,commit_sha,digest,metadata_json FROM inbox_items WHERE team_id=? AND kind='question' AND status IN ('outstanding','answered','closed') ORDER BY project_id,id`, [teamId]); + if (!rows.length) return []; + const lifecycle = await store.all(`SELECT DISTINCT ON (item_id) item_id,event_type,payload_json FROM inbox_events WHERE team_id=? + AND event_type IN ('question.answered','question.reopened') ORDER BY item_id,sequence DESC`, [teamId]); + const latest = new Map(); + for (const event of lifecycle) if (!latest.has(text(event.item_id))) latest.set(text(event.item_id), event); return rows.flatMap((row: Row) => { - const metadata = record(row.metadata_json), answer = record(metadata.answerProvenance); + const metadata = record(row.metadata_json), event = latest.get(text(row.id)), proof = record(event?.payload_json); + const relatedProposalIds = strings(metadata.related_proposals, metadata.relatedProposals), relatedWorkItemIds = strings(metadata.related_work_items, metadata.relatedWorkItems); + if (text(metadata.severity) === 'advisory' || !relatedProposalIds.length || !relatedWorkItemIds.length) return []; const repositoryId = text(row.repository_id), path = text(row.content_path), commit = text(row.commit_sha), digest = text(row.digest); - if (!repositoryId || !path || !/^[a-f0-9]{40}$/u.test(commit) || !digest) return []; - const answerCommit = text(answer.commit), answerPath = text(answer.path), answerDigest = text(answer.digest); - const answerRef = /^[a-f0-9]{40}$/u.test(answerCommit) && answerPath && answerDigest ? { - model: 'discussion_message' as const, id: text(answer.id) || answerPath, revision: Math.max(1, Number(answer.revision) || 1), - digest: answerDigest, repositoryId: text(answer.repositoryId) || repositoryId, path: answerPath, commit: answerCommit, - } : null; + if (!repositoryId || !path || !/^[a-f0-9]{40}$/u.test(commit) || !digest) throw Object.assign(new Error('A scoped blocking question lacks exact TreeDX authority.'), { code: 'inbox_question_provenance_invalid' }); + const questionRef = inboxQuestionReference(row), parsedQuestion = exactEntityReferenceSchema.safeParse(proof.questionRef); + const parsedAnswer = exactEntityReferenceSchema.safeParse(proof.answerRef); + const answerRef = event?.event_type === 'question.answered' && parsedQuestion.success && isDeepStrictEqual(parsedQuestion.data, questionRef) + && parsedAnswer.success && parsedAnswer.data.store === 'treedx' + && parsedAnswer.data.model === 'discussion-message' && parsedAnswer.data.repository === repositoryId ? parsedAnswer.data : null; return [{ teamId: text(row.team_id), projectId: text(row.project_id), id: text(row.id), revision: Math.max(1, Number(row.version) || 1), - digest, status: row.status === 'answered' ? 'answered' as const : row.status === 'closed' ? 'closed' as const : 'open' as const, + digest: digest.startsWith('sha256:') ? digest : `sha256:${digest}`, status: row.status === 'answered' ? 'answered' as const : row.status === 'closed' ? 'closed' as const : 'open' as const, severity: text(metadata.severity) === 'advisory' ? 'advisory' as const : 'blocking' as const, title: text(row.title), repositoryId, path, commit, - relatedProposalIds: strings(metadata.related_proposals, metadata.relatedProposals), + relatedProposalIds, relatedDecisionIds: strings(metadata.related_decisions, metadata.relatedDecisions), - relatedWorkItemIds: strings(metadata.related_work_items, metadata.relatedWorkItems), answerRef }]; + relatedWorkItemIds, answerRef }]; }); } diff --git a/src/api/control-plane/catalog/operation-registry.ts b/src/api/control-plane/catalog/operation-registry.ts index 5bb226482..6bd1a2446 100644 --- a/src/api/control-plane/catalog/operation-registry.ts +++ b/src/api/control-plane/catalog/operation-registry.ts @@ -55,6 +55,19 @@ export class ControlPlaneOperationError extends Error { } } +export function controlPlaneProblem(error: ControlPlaneOperationError, requestId: string, instance: string) { + const title = error.status === 400 ? 'Invalid request' + : error.status === 401 ? 'Authentication required' + : error.status === 403 ? 'Access denied' + : error.status === 404 ? 'Not found' + : error.status === 409 ? 'Conflict' + : error.status === 412 ? 'Precondition failed' + : error.status === 429 ? 'Rate limited' + : error.status === 503 ? 'Service unavailable' : 'Operation failed'; + return { type: `https://treeseed.dev/problems/${error.code}`, title, status: error.status, + code: error.code, detail: error.message, instance, requestId }; +} + export class OperationRegistry { readonly catalog: ControlPlaneCatalog; readonly operations: ReadonlyMap; diff --git a/src/api/control-plane/http/operation-http-handler.ts b/src/api/control-plane/http/operation-http-handler.ts index 231fc2aff..24dc4e047 100644 --- a/src/api/control-plane/http/operation-http-handler.ts +++ b/src/api/control-plane/http/operation-http-handler.ts @@ -2,30 +2,15 @@ import { createHash, randomUUID } from 'node:crypto'; import type { AuthInfo } from '@modelcontextprotocol/server'; import type { Context } from 'hono'; import { ZodError } from 'zod'; -import { ControlPlaneOperationError, type BoundOperation } from '../catalog/operation-registry.ts'; +import { controlPlaneProblem, ControlPlaneOperationError, type BoundOperation } from '../catalog/operation-registry.ts'; import { enforceOperationAuthorization, readOperationPreconditions } from '../authorization/invocation-policy.ts'; import { CONFIRMATION_HEADER, decodeConfirmation, type ConfirmationService } from '../confirmation/confirmation-service.ts'; type Authenticate = (request: Request) => Promise; function problem(context: Context, error: ControlPlaneOperationError, requestId: string) { - const title = error.status === 400 ? 'Invalid request' - : error.status === 401 ? 'Authentication required' - : error.status === 403 ? 'Access denied' - : error.status === 404 ? 'Not found' - : error.status === 409 ? 'Conflict' - : error.status === 412 ? 'Precondition failed' - : error.status === 429 ? 'Rate limited' - : error.status === 503 ? 'Service unavailable' : 'Operation failed'; - return context.json({ - type: `https://treeseed.dev/problems/${error.code}`, - title, - status: error.status, - code: error.code, - detail: error.message, - instance: new URL(context.req.url).pathname, - requestId, - }, error.status, { 'content-type': 'application/problem+json', 'x-request-id': requestId }); + return context.json(controlPlaneProblem(error, requestId, new URL(context.req.url).pathname), + error.status, { 'content-type': 'application/problem+json', 'x-request-id': requestId }); } function etag(value: unknown) { diff --git a/src/api/control-plane/inbox/inbox-service.ts b/src/api/control-plane/inbox/inbox-service.ts index 3e7cf6bf6..09cc7d3cc 100644 --- a/src/api/control-plane/inbox/inbox-service.ts +++ b/src/api/control-plane/inbox/inbox-service.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { commitInboxQuestion } from './question-content.ts'; +import { commitInboxQuestion, inboxQuestionReference, readInboxQuestionAnswer } from './question-content.ts'; import { loadDiscussions } from '../../discussions/content.ts'; import { parseFrontmatterDocument } from '../../content/frontmatter.ts'; import { resolveKnowledgeGatewayConnection } from '../../knowledge/gateway-treedx-connection.ts'; @@ -35,7 +35,7 @@ export function createInboxService(input: { store: any; discussions: any; commun const path=text(signal.subject_id),commitSha=text(signal.commit_sha);if(!path||!commitSha)continue; const existing=await store.first("SELECT id,commit_sha FROM inbox_items WHERE team_id=? AND kind='question' AND project_id=? AND content_path=? LIMIT 1",[teamId,signal.project_id,path]);if(text(existing?.commit_sha)===commitSha)continue; try{ - const connection=await resolveKnowledgeGatewayConnection(store,{projectId:signal.project_id,write:false,readRefs:[commitSha]});if(!connection)continue; + const connection=await resolveKnowledgeGatewayConnection(store,{projectId:signal.project_id,write:false,readRefs:[commitSha],workspacePaths:[path]});if(!connection)continue; const read=await connection.client.readRepositoryFiles({repoId:connection.repositoryId,ref:commitSha,paths:[path],encoding:'utf8',parseFrontmatter:false,allowProtected:true});const file=(read.files??[])[0]; if(!file){if(existing){await store.run("UPDATE inbox_items SET status='deleted',updated_at=? WHERE id=?",[new Date().toISOString(),existing.id]);await store.run('DELETE FROM inbox_drafts WHERE item_id=?',[existing.id]);}continue;} const source=text(file.content),parsed=parseFrontmatterDocument(source),frontmatter=record(parsed.frontmatter); @@ -62,12 +62,22 @@ export function createInboxService(input: { store: any; discussions: any; commun } async function syncAgentAnswer(row: Row) { if (row.kind !== 'question' || row.status !== 'outstanding' || !row.discussion_id) return row; + // A reply supplies evidence; only the managed answer operation releases + // blocking execution authority. Reading the inbox must not approve work. + if (text(record(row.metadata_json).severity) !== 'advisory') return row; + const head = await questionLifecycleHead(row); const history = await loadDiscussions({ store, projectId: row.project_id, discussionId: row.discussion_id, collection: 'messages', limit: 500 }).catch(() => ({ messages: [] })); - const answer = history.messages.find((message: Row) => text(record(message.frontmatter).authorType) === 'agent'); + const reopened = await store.first("SELECT occurred_at FROM inbox_events WHERE team_id=? AND item_id=? AND event_type='question.reopened' ORDER BY sequence DESC LIMIT 1", [row.team_id,row.id]); + const reopenedAt = reopened ? Date.parse(timestamp(reopened.occurred_at)) : -Infinity; + const answer = history.messages.find((message: Row) => text(record(message.frontmatter).authorType) === 'agent' + && text(record(message.frontmatter).inboxIntent) === 'answer' && Date.parse(text(record(message.frontmatter).createdAt)) > reopenedAt); if (!answer) return row; const frontmatter = record(answer.frontmatter), now = timestamp(frontmatter.createdAt); - await store.run("UPDATE inbox_items SET status='answered',updated_at=? WHERE id=? AND status='outstanding'", [now,row.id]); - await event(row.team_id,row.id,'question.answered',text(frontmatter.authorId,'agent'),'Agent answer received.',{ messageRef: answer.path }); + const answerProvenance = await readInboxQuestionAnswer({ store, projectId: row.project_id, discussionId: row.discussion_id, + path: text(answer.path), commit: text(answer.immutableRef), authorId: text(frontmatter.authorId) }); + await transitionQuestion(row, head, 'answer', text(frontmatter.authorId,'agent'), row.discussion_id, + { messageRef: answer.path, answerRef: answerProvenance, questionRef: inboxQuestionReference(row) }, now); + await reconcileExecutionGraph(store,row.team_id,{projectId:row.project_id},`inbox-agent-answer:${row.id}:${answerProvenance.commit}`); return { ...row, status: 'answered', updated_at: now }; } async function view(row: Row) { const slug = await projectSlug(row.project_id); return { schemaVersion: 'treeseed.inbox-item/v1', id: text(row.id), teamId: text(row.team_id), projectId: text(row.project_id), projectSlug: slug, @@ -76,6 +86,30 @@ export function createInboxService(input: { store: any; discussions: any; commun availableActions: actions(row), comments: await comments(row), etag: etag(row) }; } async function item(teamId: string, itemId: string) { const row = await store.first('SELECT * FROM inbox_items WHERE team_id=? AND id=? LIMIT 1',[teamId,itemId]); if (!row) throw new InboxServiceError(404,'inbox_item_not_found','Inbox item not found.'); return row; } async function event(teamId: string, itemId: string|null, type: string, actorId: string, summary: string, payload: Row = {}) { const id=`inbox-event-${randomUUID()}`, now=new Date().toISOString(); await store.run('INSERT INTO inbox_events (id,team_id,item_id,event_type,actor_id,summary,payload_json,occurred_at) VALUES (?,?,?,?,?,?,?::jsonb,?)',[id,teamId,itemId,type,actorId,summary,JSON.stringify(payload),now]); return id; } + async function questionLifecycleHead(row: Row) { + return Number((await store.first("SELECT MAX(sequence) AS sequence FROM inbox_events WHERE team_id=? AND item_id=? AND event_type IN ('question.answered','question.reopened')", [row.team_id,row.id]))?.sequence ?? 0); + } + async function transitionQuestion(row: Row, head: number, action: 'answer'|'reopen', actorId: string, discussionId: string|null, payload: Row, now: string) { + // The first command locks the row; the next command gets a fresh native + // PostgreSQL snapshot AFTER any prior writer. Proof append and lifecycle + // projection are one atomic write, conditional on the original source, + // displayed state and durable event head. No timestamp-only ABA guard. + const id=`inbox-event-${randomUUID()}`; + const result = await store.batch([ + { query: 'SELECT id FROM inbox_items WHERE team_id=? AND id=? FOR UPDATE', params: [row.team_id,row.id] }, + { query: `WITH changed AS (UPDATE inbox_items SET status=?,discussion_id=COALESCE(discussion_id,?), + metadata_json=CASE WHEN ?='reopen' THEN COALESCE(metadata_json,'{}'::jsonb)-'answerProvenance' ELSE metadata_json END,updated_at=? + WHERE team_id=? AND id=? AND version=? AND updated_at=? AND status=? + AND commit_sha IS NOT DISTINCT FROM ? AND digest IS NOT DISTINCT FROM ? AND content_path IS NOT DISTINCT FROM ? + AND COALESCE((SELECT MAX(sequence) FROM inbox_events WHERE team_id=? AND item_id=? AND event_type IN ('question.answered','question.reopened')),0)=? + RETURNING id) INSERT INTO inbox_events (id,team_id,item_id,event_type,actor_id,summary,payload_json,occurred_at) + SELECT ?,?,changed.id,?,?,?,?::jsonb,? FROM changed RETURNING id`, + params: [action==='answer'?'answered':'outstanding',discussionId,action,now,row.team_id,row.id,row.version,timestamp(row.updated_at),row.status, + row.commit_sha,row.digest,row.content_path,row.team_id,row.id,head,id,row.team_id, + action==='answer'?'question.answered':'question.reopened',actorId,action==='answer'?'Question answered.':'Question reopened.',JSON.stringify(payload),now] }, + ]); + if (!Array.isArray(result) || result[1]?.results?.[0]?.id !== id) throw new InboxServiceError(412,'inbox_item_precondition_failed','The question or its lifecycle changed while the answer was being verified.'); + } return { async list(principal: Principal, teamId: string, query: Row) { await authorize(principal,teamId); await Promise.all([syncProposals(teamId),syncQuestions(teamId)]); const clauses=['team_id=?'], values:any[]=[teamId]; if (query.projectId) {clauses.push('project_id=?');values.push(query.projectId);} if (query.kind && query.kind!=='all') {clauses.push('kind=?');values.push(query.kind);} if (query.state!=='all') clauses.push("status='outstanding'"); if (query.cursor){clauses.push('updated_at{const full=await view(await syncAgentAnswer(row));const {schemaVersion,markdown,provenance,availableActions,comments,discussionId,...summary}=full;void schemaVersion;return summary;})); return {schemaVersion:'treeseed.inbox-page/v1',items,cursor:rows.length===limit?timestamp(rows.at(-1)?.updated_at):null}; }, async show(principal: Principal, teamId: string, itemId: string) { await authorize(principal,teamId); await Promise.all([syncProposals(teamId),syncQuestions(teamId)]); return view(await syncAgentAnswer(await item(teamId,itemId))); }, @@ -85,8 +119,23 @@ export function createInboxService(input: { store: any; discussions: any; commun async putDraft(principal:Principal,teamId:string,itemId:string,purpose:string,body:Row,ifMatch?:string){await authorize(principal,teamId,true);await item(teamId,itemId);const existing=await store.first('SELECT * FROM inbox_drafts WHERE item_id=? AND principal_id=? AND purpose=?',[itemId,principal!.id,purpose]);const expected=existing?`draft:${itemId}:${existing.revision}`:'draft:new';if(ifMatch!==expected)throw new InboxServiceError(412,'inbox_draft_precondition_failed','The draft changed after it was loaded.');const revision=Number(existing?.revision??-1)+1,now=new Date().toISOString();await store.run(`INSERT INTO inbox_drafts (item_id,principal_id,purpose,markdown,base_version,revision,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT (item_id,principal_id,purpose) DO UPDATE SET markdown=EXCLUDED.markdown,base_version=EXCLUDED.base_version,revision=EXCLUDED.revision,updated_at=EXCLUDED.updated_at`,[itemId,principal!.id,purpose,body.markdown,body.baseVersion,revision,now]);await event(teamId,itemId,'draft.updated',principal!.id,'Private draft saved.');return{schemaVersion:'treeseed.inbox-draft/v1',itemId,purpose,markdown:body.markdown,baseVersion:body.baseVersion,revision,updatedAt:now,etag:`draft:${itemId}:${revision}`};}, async deleteDraft(principal:Principal,teamId:string,itemId:string,purpose:string){await authorize(principal,teamId,true);await store.run('DELETE FROM inbox_drafts WHERE item_id=? AND principal_id=? AND purpose=?',[itemId,principal!.id,purpose]);return{deleted:true};}, async action(principal:Principal,teamId:string,itemId:string,body:Row,idempotencyKey?:string,ifMatch?:string){await authorize(principal,teamId,true);if(!idempotencyKey)throw new InboxServiceError(400,'idempotency_key_required','Idempotency-Key is required.');const replay=await store.first('SELECT result_json FROM inbox_action_receipts WHERE team_id=? AND idempotency_key=?',[teamId,idempotencyKey]);if(replay)return{...record(replay.result_json),replayed:true};let row=await item(teamId,itemId);if(ifMatch!==etag(row))throw new InboxServiceError(412,'inbox_item_precondition_failed','The item changed after it was loaded.');const action=text(body.action),now=new Date().toISOString(),receipts:Row[]=[]; - if(['comment','answer','reply'].includes(action)){if(!text(body.markdown))throw new InboxServiceError(422,'inbox_feedback_required','Markdown feedback is required.');const result=await discussions.create(principal,{teamId,projectId:row.project_id,discussionId:row.discussion_id||undefined,createDiscussion:!row.discussion_id,body:body.markdown,intent:'discuss',inboxIntent:action,replyTo:text(body.parentId)||undefined},`${idempotencyKey}:discussion`);const discussionId=text(result.discussion?.id);await store.run('UPDATE inbox_items SET discussion_id=COALESCE(discussion_id,?),status=CASE WHEN ?=\'answer\' THEN \'answered\' ELSE status END,updated_at=? WHERE id=?',[discussionId,action,now,itemId]);receipts.push({messageRef:result.message?.path,commitSha:result.commitSha});if(action==='answer'&&row.kind==='question'){const answerProvenance={id:text(result.message?.id,result.message?.path),revision:1,digest:createHash('sha256').update(text(body.markdown)).digest('hex'),repositoryId:text(row.repository_id),path:text(result.message?.path),commit:text(result.commitSha)};await store.run("UPDATE inbox_items SET metadata_json=COALESCE(metadata_json,'{}'::jsonb)||?::jsonb WHERE id=?",[JSON.stringify({answerProvenance}),itemId]);await reconcileExecutionGraph(store,teamId,{projectId:row.project_id},`inbox-answer:${itemId}:${result.commitSha}`);}if(row.kind==='proposal')await store.recordGovernanceEvent({eventType:'proposal.discussion',actorType:'user',actorId:principal!.id,teamId,projectId:row.project_id,proposalId:itemId,proposalVersion:row.version,message:body.markdown,evidence:{kind:'response',contentPath:result.message?.path,commitSha:result.commitSha}});await event(teamId,itemId,action==='answer'?'question.answered':'comment.created',principal!.id,action==='answer'?'Question answered.':'Comment added.',{messageRef:result.message?.path});} - else if(action==='reopen'){if(row.kind!=='question'||row.status!=='answered')throw new InboxServiceError(409,'inbox_reopen_invalid','Only answered questions can be reopened.');await store.run("UPDATE inbox_items SET status='outstanding',updated_at=? WHERE id=?",[now,itemId]);await event(teamId,itemId,'question.reopened',principal!.id,'Question reopened.');} + const questionHead=row.kind==='question' ? await questionLifecycleHead(row) : 0; + if(['comment','answer','reply'].includes(action)){ + if(!text(body.markdown))throw new InboxServiceError(422,'inbox_feedback_required','Markdown feedback is required.'); + const result=await discussions.create(principal,{teamId,projectId:row.project_id,discussionId:row.discussion_id||undefined,createDiscussion:!row.discussion_id,body:body.markdown,intent:'discuss',inboxIntent:action,replyTo:text(body.parentId)||undefined},`${idempotencyKey}:discussion`); + const discussionId=text(result.discussion?.id,row.discussion_id); + const answerProvenance=action==='answer'&&row.kind==='question' ? await readInboxQuestionAnswer({store,projectId:row.project_id, + discussionId,path:text(result.message?.path),commit:text(result.commitSha),authorId:principal!.id}) : null; + if(answerProvenance)await transitionQuestion(row,questionHead,'answer',principal!.id,discussionId,{messageRef:result.message?.path,answerRef:answerProvenance,questionRef:inboxQuestionReference(row)},now); + else { + await event(teamId,itemId,'comment.created',principal!.id,'Comment added.',{messageRef:result.message?.path}); + await store.run('UPDATE inbox_items SET discussion_id=COALESCE(discussion_id,?),updated_at=? WHERE id=?',[discussionId,now,itemId]); + } + receipts.push({messageRef:result.message?.path,commitSha:result.commitSha}); + if(answerProvenance)await reconcileExecutionGraph(store,teamId,{projectId:row.project_id},`inbox-answer:${itemId}:${result.commitSha}`); + if(row.kind==='proposal')await store.recordGovernanceEvent({eventType:'proposal.discussion',actorType:'user',actorId:principal!.id,teamId,projectId:row.project_id,proposalId:itemId,proposalVersion:row.version,message:body.markdown,evidence:{kind:'response',contentPath:result.message?.path,commitSha:result.commitSha}}); + } + else if(action==='reopen'){if(row.kind!=='question'||row.status!=='answered')throw new InboxServiceError(409,'inbox_reopen_invalid','Only answered questions can be reopened.');await transitionQuestion(row,questionHead,'reopen',principal!.id,row.discussion_id,{},now);await reconcileExecutionGraph(store,teamId,{projectId:row.project_id},`inbox-reopen:${itemId}:${idempotencyKey}`);} else if(action==='publish_revision'){if(row.kind!=='proposal'||!text(body.markdown)||!text(body.changeReason))throw new InboxServiceError(422,'inbox_revision_invalid','Proposal markdown and a change summary are required.');const result=await governance.updateProposal(principal,row.project_id,itemId,{body:body.markdown,changeReason:body.changeReason,expectedProposalVersion:row.version},String(row.version));receipts.push(result.authoringReceipt??{});await syncProposals(teamId);await event(teamId,itemId,'proposal.revised',principal!.id,'Proposal revision published.');} else if(['approve','reject'].includes(action)){if(row.kind!=='proposal'||!text(body.markdown))throw new InboxServiceError(422,'inbox_decision_reason_required','A decision rationale is required.');await store.adminDecideGovernanceProposal(principal,itemId,{status:action==='approve'?'approved':'rejected',reason:body.markdown,expectedProposalVersion:row.version});await syncProposals(teamId);await event(teamId,itemId,'proposal.decided',principal!.id,`Proposal ${action}d.`,{action});} else throw new InboxServiceError(400,'inbox_action_invalid','Unsupported inbox action.');row=await item(teamId,itemId);const result={schemaVersion:'treeseed.inbox-mutation-receipt/v1',item:await view(row),action,replayed:false,receipts};await store.run('INSERT INTO inbox_action_receipts (team_id,idempotency_key,item_id,action,result_json,created_at) VALUES (?,?,?,?,?::jsonb,?)',[teamId,idempotencyKey,itemId,action,JSON.stringify(result),now]);return result;}, diff --git a/src/api/control-plane/inbox/question-content.ts b/src/api/control-plane/inbox/question-content.ts index a6ac15a8f..08a5ddf7e 100644 --- a/src/api/control-plane/inbox/question-content.ts +++ b/src/api/control-plane/inbox/question-content.ts @@ -1,33 +1,71 @@ import { createHash, randomUUID } from 'node:crypto'; -import { serializeFrontmatterDocument } from '../../content/frontmatter.ts'; +import { parseFrontmatterDocument, serializeFrontmatterDocument } from '../../content/frontmatter.ts'; +import { exactEntityReferenceSchema } from '@treeseed/sdk/agent-capacity'; import { applyTextChangeset } from '../../knowledge/changesets/apply-text-changeset.ts'; import { projectLibraryPath, resolveKnowledgeGatewayConnection } from '../../knowledge/gateway-treedx-connection.ts'; import { recordTreeDxAuthoringState } from '../../capacity/services/treedx/repositories/treedx-authoring-journal.ts'; +import { discussionWorkspaceOperationKey, openDiscussionWorkspace } from '../../discussions/discussion-workspace.ts'; const slug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 72) || 'question'; +export function inboxQuestionReference(row: Record) { + const digest = String(row.digest ?? ''); + return exactEntityReferenceSchema.parse({ store: 'treedx', model: 'question', id: row.id, revision: Number(row.version), + digest: digest.startsWith('sha256:') ? digest : `sha256:${digest}`, repository: row.repository_id, + path: row.content_path, commit: row.commit_sha }); +} + export async function commitInboxQuestion(input: { store: any; teamId: string; projectId: string; principal: Record; title: string; markdown: string; severity: 'blocking' | 'advisory'; relatedObjectives: string[]; relatedProposals: string[]; relatedDecisions: string[]; relatedWorkItems: string[] }) { - const connection = await resolveKnowledgeGatewayConnection(input.store, { projectId: input.projectId, write: true }); - if (!connection) throw Object.assign(new Error('The project TreeDX repository is unavailable for question authoring.'), { status: 503, code: 'inbox_treedx_unavailable' }); + const library = await input.store.getProjectTreeDxLibrary(input.projectId); + if (!library) throw Object.assign(new Error('The project TreeDX repository is unavailable for question authoring.'), { status: 503, code: 'inbox_treedx_unavailable' }); const id = `question-${randomUUID()}`, now = new Date().toISOString(); - const path = projectLibraryPath(connection.contentPath, 'questions', `${slug(input.title)}-${id.slice(-8)}.mdx`); + const path = projectLibraryPath(library.contentPath, 'questions', `${slug(input.title)}-${id.slice(-8)}.mdx`); + const connection = await resolveKnowledgeGatewayConnection(input.store, { projectId: input.projectId, write: true, workspacePaths: [path] }); + if (!connection) throw Object.assign(new Error('The project TreeDX repository is unavailable for question authoring.'), { status: 503, code: 'inbox_treedx_unavailable' }); const source = serializeFrontmatterDocument({ id, title: input.title, status: 'open', severity: input.severity, questionType: 'strategy', relatedObjectives: input.relatedObjectives, relatedProposals: input.relatedProposals, relatedDecisions: input.relatedDecisions, relatedWorkItems: input.relatedWorkItems, createdBy: input.principal.id, createdAt: now, updatedAt: now }, `${input.markdown.trim()}\n`); const branchName = `refs/heads/${connection.authoringBranch.replace(/^refs\/heads\//u, '')}`; - const workspace = await connection.client.createWorkspace({ repoId: connection.repositoryId, baseRef: connection.baseRef, branchName }); + const session = await openDiscussionWorkspace({ store: input.store, connection: { ...connection, allowedPaths: [path] }, + projectId: input.projectId, baseRef: connection.baseRef, branchName, operationKey: discussionWorkspaceOperationKey('question', id) }); + const workspace = session.workspace; try { await applyTextChangeset({ client: connection.client, workspace, changes: [{ path, before: null, after: source }] }); const commit = await connection.client.commit({ workspaceId: workspace.workspaceId, message: `question: ${input.title}`, author: { name: input.principal.displayName ?? input.principal.id, email: input.principal.email ?? 'inbox@users.treeseed.local' } }); - const reader = await resolveKnowledgeGatewayConnection(input.store, { projectId: input.projectId, write: false, readRefs: [commit.commitSha] }); + const reader = await resolveKnowledgeGatewayConnection(input.store, { projectId: input.projectId, write: false, readRefs: [commit.commitSha], workspacePaths: [path] }); if (!reader) throw new Error('TreeDX exact-ref question read-back is unavailable.'); const observed = await reader.client.readRepositoryFiles({ repoId: connection.repositoryId, ref: commit.commitSha, paths: [path], encoding: 'utf8', parseFrontmatter: false, allowProtected: true }); - if (String((observed as any).resolvedRef) !== commit.commitSha || String((observed.files ?? [])[0]?.content ?? '').trimEnd() !== source.trimEnd()) throw Object.assign(new Error('Question did not pass exact TreeDX read-back.'), { status: 502, code: 'inbox_question_readback_failed' }); + if (String((observed as any).resolvedRef) !== commit.commitSha || observed.files?.length !== 1 || observed.files[0]?.path !== path || observed.files[0]?.content !== source) throw Object.assign(new Error('Question did not pass exact TreeDX read-back.'), { status: 502, code: 'inbox_question_readback_failed' }); await recordTreeDxAuthoringState(input.store, 'integrated', { projectId: input.projectId, repositoryId: connection.repositoryId, commitSha: commit.commitSha, ref: commit.branchName, changedPaths: commit.changedPaths, assignmentId: null, actorType: 'user', actorId: input.principal.id }); - await connection.client.closeWorkspace(workspace.workspaceId).catch(() => undefined); return { id, path, source, commitSha: commit.commitSha, repositoryId: connection.repositoryId, digest: createHash('sha256').update(source).digest('hex'), createdAt: now }; - } catch (error) { await connection.client.closeWorkspace(workspace.workspaceId).catch(() => undefined); throw error; } + } finally { await session.close(); } +} + +/** Resolve a question only from its exact committed answer, never a body digest + * or a mutable discussion branch. Both human and agent answers use this path. */ +export async function readInboxQuestionAnswer(input: { store: any; projectId: string; discussionId: string; + path: string; commit: string; authorId: string }) { + const invalid = () => Object.assign(new Error('The question answer lacks exact TreeDX discussion-message authority.'), + { status: 503, code: 'inbox_answer_readback_failed' }); + if (!/^[a-f0-9]{40}$/u.test(input.commit)) throw invalid(); + const library = await input.store.getProjectTreeDxLibrary(input.projectId); + if (!library || !input.discussionId) throw invalid(); + const prefix = `${projectLibraryPath(library.contentPath, 'discussion-messages', slug(input.discussionId))}/`; + if (!input.path.startsWith(prefix) || input.path.slice(prefix.length).includes('/') || !input.path.endsWith('.mdx')) throw invalid(); + const reader = await resolveKnowledgeGatewayConnection(input.store, { projectId: input.projectId, write: false, + readRefs: [input.commit], workspacePaths: [input.path] }); + if (!reader) throw invalid(); + const observed = await reader.client.readRepositoryFiles({ repoId: reader.repositoryId, ref: input.commit, + paths: [input.path], encoding: 'utf8', parseFrontmatter: false, allowProtected: true }); + const file = observed.files?.[0]; + if (observed.resolvedRef !== input.commit || observed.files?.length !== 1 || file?.path !== input.path || typeof file.content !== 'string') throw invalid(); + const parsed = parseFrontmatterDocument(file.content), metadata = parsed.frontmatter; + if (metadata.schemaVersion !== 'treeseed.discussion-message/v1' || metadata.discussionId !== input.discussionId + || metadata.inboxIntent !== 'answer' || metadata.authorId !== input.authorId || !parsed.body.trim()) throw invalid(); + return exactEntityReferenceSchema.parse({ store: 'treedx', model: 'discussion-message', id: metadata.id, + revision: 1, digest: `sha256:${createHash('sha256').update(file.content).digest('hex')}`, + repository: reader.repositoryId, path: input.path, commit: input.commit }); } diff --git a/src/api/control-plane/mcp/create-mcp-handler.ts b/src/api/control-plane/mcp/create-mcp-handler.ts index 13b3d3185..c4360c398 100644 --- a/src/api/control-plane/mcp/create-mcp-handler.ts +++ b/src/api/control-plane/mcp/create-mcp-handler.ts @@ -1,6 +1,6 @@ import { McpServer, ResourceTemplate, acceptedContent, completable, createMcpHandler, inputRequired, type ServerEventBus } from '@modelcontextprotocol/server'; import { z } from 'zod'; -import type { OperationRegistry } from '../catalog/operation-registry.ts'; +import { controlPlaneProblem, ControlPlaneOperationError, type OperationRegistry } from '../catalog/operation-registry.ts'; import { sdkOperationInputStandardSchema, sdkStandardSchema } from '../catalog/sdk-standard-schema.ts'; import { createMcpCatalog } from './mcp-catalog.ts'; import { CONFIRMATION_HEADER, decodeConfirmation, encodeConfirmation, type ConfirmationService } from '../confirmation/confirmation-service.ts'; @@ -27,43 +27,50 @@ export function createControlPlaneMcpHandler(registry: OperationRegistry, confir openWorldHint: false, }, }, async (input, context) => { - enforceOperationAuthorization(descriptor, requestContext.authInfo); - const { headers, ...operationInput } = input as { headers?: Record; path: unknown; query: unknown; body: unknown }; - const preconditions = readOperationPreconditions(descriptor, requestContext.requestInfo?.headers, headers); - if (descriptor.confirmation === 'input_required') { - const principalId = String((requestContext.authInfo?.extra?.principal as any)?.id ?? ''); - const clientId = String(requestContext.authInfo?.clientId ?? ''); - if (!confirmations || !principalId || !clientId) return { content: [{ type: 'text', text: 'Confirmation is unavailable.' }], isError: true }; - const { [CONFIRMATION_HEADER]: supplied, ...operationHeaders } = headers ?? {}; - const identity = { principalId, clientId, operationId: descriptor.operationId, - arguments: headers ? { ...operationInput, headers: operationHeaders } : operationInput }; - const requestState = supplied ?? context.mcpReq.requestState(); - const accepted = acceptedContent<{ confirm: boolean }>(context.mcpReq.inputResponses, 'confirm'); - if (!requestState || (supplied === undefined && accepted?.confirm !== true)) { - const required = confirmations.request({ ...identity, requestId: String(context.mcpReq.id) }); - if (requestContext.era === 'legacy') return { isError: true, - content: [{ type: 'text', text: JSON.stringify(required) }] }; - return inputRequired({ requestState: encodeConfirmation(required.confirmation), inputRequests: { - confirm: inputRequired.elicit({ message: required.prompt, requestedSchema: { - type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'], additionalProperties: false, - } }), - } }); + const requestId = crypto.randomUUID(); + try { + enforceOperationAuthorization(descriptor, requestContext.authInfo); + const { headers, ...operationInput } = input as { headers?: Record; path: unknown; query: unknown; body: unknown }; + const preconditions = readOperationPreconditions(descriptor, requestContext.requestInfo?.headers, headers); + if (descriptor.confirmation === 'input_required') { + const principalId = String((requestContext.authInfo?.extra?.principal as any)?.id ?? ''); + const clientId = String(requestContext.authInfo?.clientId ?? ''); + if (!confirmations || !principalId || !clientId) return { content: [{ type: 'text', text: 'Confirmation is unavailable.' }], isError: true }; + const { [CONFIRMATION_HEADER]: supplied, ...operationHeaders } = headers ?? {}; + const identity = { principalId, clientId, operationId: descriptor.operationId, + arguments: headers ? { ...operationInput, headers: operationHeaders } : operationInput }; + const requestState = supplied ?? context.mcpReq.requestState(); + const accepted = acceptedContent<{ confirm: boolean }>(context.mcpReq.inputResponses, 'confirm'); + if (!requestState || (supplied === undefined && accepted?.confirm !== true)) { + const required = confirmations.request({ ...identity, requestId: String(context.mcpReq.id) }); + if (requestContext.era === 'legacy') return { isError: true, + content: [{ type: 'text', text: JSON.stringify(required) }] }; + return inputRequired({ requestState: encodeConfirmation(required.confirmation), inputRequests: { + confirm: inputRequired.elicit({ message: required.prompt, requestedSchema: { + type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'], additionalProperties: false, + } }), + } }); + } + const state = decodeConfirmation(requestState); + if (!state || !await confirmations.verify(state, identity)) return { content: [{ type: 'text', text: 'Confirmation is invalid, expired, changed, or already used.' }], isError: true }; } - const state = decodeConfirmation(requestState); - if (!state || !await confirmations.verify(state, identity)) return { content: [{ type: 'text', text: 'Confirmation is invalid, expired, changed, or already used.' }], isError: true }; + if (context.mcpReq.signal.aborted) throw context.mcpReq.signal.reason; + const progressToken = context.mcpReq._meta?.progressToken; + if (progressToken !== undefined) await context.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 0, total: 1 } }); + const output = await operation.handler(operationInput as any, { + interface: 'mcp', requestId, authInfo: requestContext.authInfo, + ...preconditions, + principal: requestContext.authInfo?.extra?.principal as any, signal: context.mcpReq.signal, + }); + if (context.mcpReq.signal.aborted) throw context.mcpReq.signal.reason; + if (progressToken !== undefined) await context.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 1, total: 1 } }); + const validated = operation.binding.schema.output.parse(output) as Record; + return { content: [{ type: 'text', text: JSON.stringify(validated) }], structuredContent: validated }; + } catch (error) { + if (!(error instanceof ControlPlaneOperationError)) throw error; + const problem = controlPlaneProblem(error, requestId, `mcp:${descriptor.operationId}`); + return { isError: true, content: [{ type: 'text', text: JSON.stringify(problem) }], structuredContent: problem }; } - if (context.mcpReq.signal.aborted) throw context.mcpReq.signal.reason; - const progressToken = context.mcpReq._meta?.progressToken; - if (progressToken !== undefined) await context.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 0, total: 1 } }); - const output = await operation.handler(operationInput as any, { - interface: 'mcp', requestId: crypto.randomUUID(), authInfo: requestContext.authInfo, - ...preconditions, - principal: requestContext.authInfo?.extra?.principal as any, signal: context.mcpReq.signal, - }); - if (context.mcpReq.signal.aborted) throw context.mcpReq.signal.reason; - if (progressToken !== undefined) await context.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: 1, total: 1 } }); - const validated = operation.binding.schema.output.parse(output) as Record; - return { content: [{ type: 'text', text: JSON.stringify(validated) }], structuredContent: validated }; }); } for (const resource of mcpCatalog.resources) { diff --git a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts index 8ff429a04..5fa331cb1 100644 --- a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts +++ b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts @@ -9,6 +9,7 @@ import { projectActiveWorkdays } from '../../../../capacity/policy/execution/wor import { projectCommunicationInvocations, type CommunicationProjectionSource } from '../../../../capacity/policy/execution/communication-execution-projector.ts'; import { loadTeamExecutableProposalSources } from '../../../../capacity/services/capacity/execution/executable-proposal-source.ts'; import { loadTeamExactDependencyLinks } from '../../../../capacity/services/capacity/execution/exact-dependency-links.ts'; +import { loadTeamQuestionConditionSources } from '../../../../capacity/services/capacity/execution/question-condition-source.ts'; import { readExactProposal } from '../../../../governance/executable-proposal.ts'; import { authorizeCapacityTeam, type CapacityPrincipal } from '../capacity-authorization.ts'; import { CapacityOperationError } from '../capacity-operation-error.ts'; @@ -233,7 +234,9 @@ async function reconcileExecutionGraphOnce(store: any, teamId: string, body: Row const revision = current.revision + 1; const dependencyLinks = new Set(sources.map((source) => source.projectId)).size > 1 ? await loadGraphSource('dependencies', () => loadTeamExactDependencyLinks(store, sources)) : []; - const proposalProjection = sources.length ? projectTeamExecutionGraph({ teamId, revision, sources, profiles, dependencyLinks }) : null; + const questionSources = scope === 'communication' || !sources.length ? [] + : await loadGraphSource('questions', () => loadTeamQuestionConditionSources(store, teamId)); + const proposalProjection = sources.length ? projectTeamExecutionGraph({ teamId, revision, sources, profiles, dependencyLinks, questionSources }) : null; const activeSimulationByDecision = simulationRunByDecision(workdays); const activeSimulationByProposal = simulationRunBySelection(workdays, 'proposalIds'); const decisionRun = (node: ExecutionNode) => simulationRunForNode(node, activeSimulationByDecision, activeSimulationByProposal); diff --git a/src/api/knowledge/gateway-treedx-connection.ts b/src/api/knowledge/gateway-treedx-connection.ts index 592d7911f..80ca7a189 100644 --- a/src/api/knowledge/gateway-treedx-connection.ts +++ b/src/api/knowledge/gateway-treedx-connection.ts @@ -78,12 +78,15 @@ export async function resolveKnowledgeGatewayConnection(store: any, input: { maintenanceRefs?: string[]; replicationRefs?: string[]; readRefs?: string[]; + refreshGraph?: boolean; workspaceRefs?: string[]; workspacePaths?: string[]; relationPaths?: boolean; communicationPaths?: boolean; authoringPaths?: boolean; }): Promise { + if (input.refreshGraph && (input.readRefs?.length !== 1 || !/^[a-f0-9]{40}$/u.test(input.readRefs[0] ?? ''))) + throw new Error('A graph refresh grant requires one exact commit.'); const library = await store.getProjectTreeDxLibrary(input.projectId); if (!library) return null; const topology = record(library.topology); @@ -130,7 +133,7 @@ export async function resolveKnowledgeGatewayConnection(store: any, input: { ? ['repos:read', 'files:read', 'files:search', 'git:read', 'git:fetch', 'git:push', 'registry:read', 'graph:query', 'graph:refresh'] : input.write ? ['repos:read', 'repos:write', 'workspace:create', 'files:read', 'files:search', 'files:write', 'files:delete', 'git:read', 'git:diff', 'git:commit', 'graph:query', 'graph:refresh'] - : ['repos:read', 'files:read', 'files:search', 'git:read', 'git:diff', 'graph:query'], + : ['repos:read', 'files:read', 'files:search', 'git:read', 'git:diff', 'graph:query', ...(input.refreshGraph ? ['graph:refresh'] : [])], refs: [...new Set([text(library.contentRepositoryRef, library.contentRepositoryDefaultBranch, 'main'), canonicalTreeDxBranchRef(library.contentRepositoryDefaultBranch ?? 'main'), ...(input.write || input.communicationPaths || input.authoringPaths ? [canonicalAuthoringRef] : []), @@ -153,3 +156,25 @@ export async function resolveKnowledgeGatewayConnection(store: any, input: { publicationRef: canonicalAuthoringRef, }; } + +/** Shared observation of the existing native graph-refresh job. */ +export function treeDxResult(value: unknown, key: string) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} as Record; + const record = value as Record; + return record[key] && typeof record[key] === 'object' ? record[key] as Record : record; +} + +export async function completedGraphRefresh(client: { + refreshGraph(input: Record): Promise; + getGraphRefreshJob(input: Record): Promise; +}, input: Record) { + const refresh = treeDxResult(await client.refreshGraph(input), 'graph'); + if (!refresh.jobId) return refresh; + for (let attempt = 0; attempt < 60; attempt += 1) { + const job = treeDxResult(await client.getGraphRefreshJob({ repoId: input.repoId, ref: input.ref, jobId: refresh.jobId }), 'job'); + if (job.status === 'completed') return { ...refresh, graphVersion: job.graphVersion ?? refresh.graphVersion }; + if (job.status === 'failed') throw new Error(`TreeDX graph refresh failed: ${job.errorCode ?? 'unknown error'}.`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error('TreeDX graph refresh did not complete before the publication deadline.'); +} diff --git a/src/operations-runner/knowledge/publication-executor.ts b/src/operations-runner/knowledge/publication-executor.ts index 40493463a..ee938cd80 100644 --- a/src/operations-runner/knowledge/publication-executor.ts +++ b/src/operations-runner/knowledge/publication-executor.ts @@ -1,4 +1,4 @@ -import { resolveKnowledgeGatewayConnection } from '../../api/knowledge/gateway-treedx-connection.ts'; +import { completedGraphRefresh, resolveKnowledgeGatewayConnection, treeDxResult } from '../../api/knowledge/gateway-treedx-connection.ts'; import { buildKnowledgePublication } from '../../api/knowledge/build-publication.ts'; import { loadKnowledgeSnapshotProjects } from '../../api/knowledge/snapshot-projects.ts'; import { createKnowledgePublicationStorage } from '../../api/knowledge/publication-storage.ts'; @@ -31,24 +31,6 @@ export function knowledgeRunnerEnvironment(options: any) { ?? process.env.TREESEED_PLATFORM_RUNNER_ENVIRONMENT; } -export function treeDxResult(value: unknown, key: string) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} as any; - const record = value as Record; - return record[key] && typeof record[key] === 'object' ? record[key] : record; -} - -export async function completedGraphRefresh(client: any, input: any) { - const refresh = treeDxResult(await client.refreshGraph(input), 'graph'); - if (!refresh.jobId) return refresh; - for (let attempt = 0; attempt < 60; attempt += 1) { - const job = treeDxResult(await client.getGraphRefreshJob({ repoId: input.repoId, ref: input.ref, jobId: refresh.jobId }), 'job'); - if (job.status === 'completed') return { ...refresh, graphVersion: job.graphVersion ?? refresh.graphVersion }; - if (job.status === 'failed') throw new Error(`TreeDX graph refresh failed: ${job.errorCode ?? 'unknown error'}.`); - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error('TreeDX graph refresh did not complete before the publication deadline.'); -} - export function requireIndexedSourceClosure(input: { projectId: string; commitSha: string; graph?: any; search?: any; }) { diff --git a/tests/unit/control-plane/capacity/execution/graph/questions/inbox-question-gates.test.ts b/tests/unit/control-plane/capacity/execution/graph/questions/inbox-question-gates.test.ts new file mode 100644 index 000000000..234c63bfd --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/graph/questions/inbox-question-gates.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from 'vitest'; +import { graphProfiles, graphSource, emptyLivingGraph } from '../architecture/living/living-graph-fixture.ts'; +import { projectTeamExecutionGraph } from '../../../../../../../src/api/capacity/policy/execution/execution-graph-projector.ts'; +import { applyOperationalState } from '../../../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-state.ts'; +import { loadTeamQuestionConditionSources } from '../../../../../../../src/api/capacity/services/capacity/execution/question-condition-source.ts'; +import { inboxQuestionReference } from '../../../../../../../src/api/control-plane/inbox/question-content.ts'; + +const answerRef = { store: 'treedx' as const, model: 'discussion-message', id: 'answer', revision: 1, + digest: `sha256:${'c'.repeat(64)}`, repository: 'project-library', commit: 'c'.repeat(40), path: 'discussion-messages/runtime/answer.mdx' }; +const question = () => ({ teamId: 'team', projectId: 'project', id: 'runtime-handoff', revision: 1, + digest: `sha256:${'d'.repeat(64)}`, status: 'open' as const, severity: 'blocking' as const, title: 'Adopt reviewed transport', + repositoryId: 'project-library', path: 'questions/runtime-handoff.mdx', commit: 'd'.repeat(40), + relatedProposalIds: ['project-proposal'], relatedDecisionIds: ['project-decision'], relatedWorkItemIds: ['next'], answerRef: null }); +const project = (questions: Array | Omit, 'status' | 'answerRef'> & { status: 'answered' | 'closed'; answerRef: typeof answerRef | null }>) => { + const graph = projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [graphSource()], profiles: graphProfiles(), questionSources: questions }); + return applyOperationalState(emptyLivingGraph(), { ...graph, teamId: 'team', revision: 1, digest: graph.revision.graphDigest }, 1); +}; + +it('holds only the named contribution while unrelated work retains accepted authority', () => { + const graph = project([question()]), gate = graph.nodes.find(node => node.condition?.subjectRef.model === 'question'); + expect(gate).toMatchObject({ status: 'blocked', condition: { conditionType: 'question', subjectRef: { id: 'runtime-handoff' } } }); + const targets = graph.edges.filter(edge => edge.fromNodeId === gate!.id).map(edge => graph.nodes.find(node => node.id === edge.toNodeId)); + expect(targets).toMatchObject([{ workItemId: 'next', pairRole: 'actor' }]); + expect(graph.nodes.find(node => node.workItemId === 'first' && node.pairRole === 'actor')?.status).toBe('ready'); + expect(graph.nodes.find(node => node.condition?.conditionType === 'authority')?.status).toBe('completed'); +}); + +it.each(['answered-without-proof', 'closed-with-proof', 'answered-with-proof'] as const)('requires an answered question and exact answer authority: %s', mode => { + const graph = project([{ ...question(), status: mode === 'closed-with-proof' ? 'closed' : 'answered', + answerRef: mode === 'answered-without-proof' ? null : answerRef }]); + expect(graph.nodes.find(node => node.condition?.subjectRef.model === 'question')).toMatchObject({ + status: mode === 'answered-with-proof' ? 'completed' : 'blocked', + authorityRefs: mode === 'answered-with-proof' ? [answerRef] : [], + }); +}); + +it.each([ + { teamId: 'other' }, { projectId: 'other' }, { severity: 'advisory' }, { relatedProposalIds: [] }, + { relatedProposalIds: ['other'] }, { relatedDecisionIds: ['other'] }, { relatedWorkItemIds: [] }, { relatedWorkItemIds: ['absent'] }, +])('does not attach an unrelated or advisory question: %j', patch => { + const graph = project([{ ...question(), ...patch } as ReturnType]); + expect(graph.nodes.filter(node => node.condition?.subjectRef.model === 'question')).toEqual([]); +}); + +it('indexes only canonical exact discussion-message answer provenance and preserves source input', async () => { + const base = question(), row = { id: base.id, team_id: base.teamId, project_id: base.projectId, status: 'answered', + title: base.title, version: 1, repository_id: base.repositoryId, content_path: base.path, commit_sha: base.commit, + digest: 'd'.repeat(64), metadata_json: { relatedProposals: base.relatedProposalIds, relatedWorkItems: ['next'], answerProvenance: answerRef } }; + for (const reference of [answerRef, { ...answerRef, model: 'discussion_message' }, { ...answerRef, digest: 'body-only-digest' }, + { ...answerRef, repository: 'foreign' }, { ...answerRef, commit: 'moved' }]) { + const supplied = { ...row, metadata_json: { ...row.metadata_json, answerProvenance: reference } }, held = structuredClone(supplied); + const event = { item_id: row.id, event_type: 'question.answered', payload_json: { answerRef: reference, questionRef: inboxQuestionReference(row) } }; + const loaded = await loadTeamQuestionConditionSources({ all: async (sql: string) => sql.includes('FROM inbox_events') ? [event] : [supplied] }, 'team'); + expect(loaded[0]?.answerRef).toEqual(reference === answerRef ? answerRef : null); + expect(loaded[0]?.digest).toBe(`sha256:${row.digest}`); expect(supplied).toEqual(held); + } +}); + +it.each(['no-event', 'reopened', 'changed-question'] as const)('does not trust source metadata or reuse superseded answer proof: %s', mode => { + const base = question(), row = { id: base.id, team_id: base.teamId, project_id: base.projectId, status: 'answered', + title: base.title, version: 1, repository_id: base.repositoryId, content_path: base.path, commit_sha: base.commit, + digest: 'd'.repeat(64), metadata_json: { relatedProposals: base.relatedProposalIds, relatedWorkItems: ['next'], answerProvenance: answerRef } }; + const event = { item_id: row.id, event_type: mode === 'reopened' ? 'question.reopened' : 'question.answered', + payload_json: { answerRef, questionRef: { ...inboxQuestionReference(row), ...(mode === 'changed-question' ? { commit: 'e'.repeat(40) } : {}) } } }; + return loadTeamQuestionConditionSources({ all: async (sql: string) => sql.includes('FROM inbox_events') ? mode === 'no-event' ? [] : [event] : [row] }, 'team') + .then(loaded => expect(loaded[0]?.answerRef).toBeNull()); +}); diff --git a/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts b/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts index 89745cb70..0256b0fc0 100644 --- a/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts @@ -31,6 +31,50 @@ const noteBytes = `---\n${stringify(note)}---\n\n${note.body}\n`; beforeEach(() => { resolve.mockReset(); }); describe('exact TreeDX dependency intake', () => { + it('rebuilds only an unready exact snapshot through a scoped refresh grant before reading its links', async () => { + const notReady = Object.assign(new Error('Graph is not ready.'), { code: 'graph_not_ready' }); + const client = { listRepositoryPaths: vi.fn(async () => ({ resolvedRef: graph.resolvedRef })), + queryGraph: vi.fn().mockRejectedValueOnce(notReady).mockResolvedValue(graph), + refreshGraph: vi.fn(async () => ({ graph: { jobId: 'exact-refresh', resolvedRef: graph.resolvedRef } })), + getGraphRefreshJob: vi.fn(async () => ({ job: { status: 'completed', graphVersion: 'exact-index' } })), + readRepositoryFile: vi.fn(async () => ({ resolvedRef: graph.resolvedRef, + file: { path: 'notes/dependency.md', content: noteBytes, frontmatter: note } })) }; + resolve.mockImplementation(async (_store, input) => ({ repositoryId: `${input.projectId}-library`, contentPath: '.', + baseRef: 'staging', allowedPaths: ['notes/**', 'proposals/**'], client: input.projectId === 'sdk' ? client : { + listRepositoryPaths: async () => ({ resolvedRef: 'd'.repeat(40) }), + queryGraph: async () => ({ resolvedRef: 'd'.repeat(40), nodes: [], edges: [] }) } })); + expect(await loadTeamExactDependencyLinks({}, [sdk, api] as never)).toHaveLength(1); + expect(resolve).toHaveBeenCalledWith({}, { projectId: 'sdk', write: false, relationPaths: true, + readRefs: [graph.resolvedRef], refreshGraph: true }); + expect(client.refreshGraph).toHaveBeenCalledWith({ repoId: 'sdk-library', ref: graph.resolvedRef, + paths: ['notes/**', 'proposals/**'] }); + expect(client.getGraphRefreshJob).toHaveBeenCalledWith({ repoId: 'sdk-library', ref: graph.resolvedRef, jobId: 'exact-refresh' }); + expect(client.queryGraph).toHaveBeenCalledTimes(2); + expect(client.queryGraph.mock.calls[0]).toEqual(client.queryGraph.mock.calls[1]); + }); + + it.each(['permission_denied', 'authentication_required', 'transport_failed'])('does not reinterpret %s as an empty or unready dependency graph', async (code) => { + const error = Object.assign(new Error(code), { code }); + resolve.mockResolvedValue({ repositoryId: 'sdk-library', contentPath: '.', baseRef: 'staging', allowedPaths: ['notes/**'], + client: { listRepositoryPaths: async () => ({ resolvedRef: graph.resolvedRef }), queryGraph: async () => { throw error; } } }); + await expect(loadTeamExactDependencyLinks({}, [sdk, api] as never)).rejects.toBe(error); + expect(resolve.mock.calls.some(([, input]) => input.refreshGraph)).toBe(false); + }); + it.each(['missing-grant', 'wrong-repository', 'failed-job', 'still-unready'])('retains the dependency failure after %s', async (failure) => { + const notReady = Object.assign(new Error('Graph is not ready.'), { code: 'graph_not_ready' }); + const client = { listRepositoryPaths: async () => ({ resolvedRef: graph.resolvedRef }), + queryGraph: vi.fn(async () => { throw notReady; }), + refreshGraph: vi.fn(async () => ({ graph: { jobId: 'exact-refresh' } })), + getGraphRefreshJob: vi.fn(async () => ({ job: { status: failure === 'failed-job' ? 'failed' : 'completed', errorCode: 'native-index-failed' } })) }; + resolve.mockImplementation(async (_store, input) => input.refreshGraph && failure === 'missing-grant' ? null : { + repositoryId: input.refreshGraph && failure === 'wrong-repository' ? 'foreign-library' : 'sdk-library', + contentPath: '.', baseRef: 'staging', allowedPaths: ['notes/**'], client }); + await expect(loadTeamExactDependencyLinks({}, [sdk, api] as never)).rejects.toThrow( + failure === 'failed-job' ? 'native-index-failed' : failure === 'still-unready' ? 'Graph is not ready.' : 'refresh grant is missing'); + expect(client.queryGraph).toHaveBeenCalledTimes(failure === 'still-unready' ? 2 : 1); + if (failure === 'missing-grant' || failure === 'wrong-repository') expect(client.refreshGraph).not.toHaveBeenCalled(); + }); + it('requires a scoped grant, exact graph commit, and matching note bytes', async () => { const client = { listRepositoryPaths: vi.fn(async () => ({ resolvedRef: 'c'.repeat(40) })), queryGraph: vi.fn(async () => graph), readRepositoryFile: vi.fn(async () => ({ resolvedRef: 'c'.repeat(40), diff --git a/tests/unit/control-plane/governance/proposals/architecture/proposal-decision-native.test.ts b/tests/unit/control-plane/governance/proposals/architecture/proposal-decision-native.test.ts index cf5e35413..ed17a8e83 100644 --- a/tests/unit/control-plane/governance/proposals/architecture/proposal-decision-native.test.ts +++ b/tests/unit/control-plane/governance/proposals/architecture/proposal-decision-native.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest'; import { validateDecisionAuthority, validateExecutionAuthorityReceipt } from '../../../../../../src/api/governance/decision-authority.ts'; import { prepareNativeProposalVoting } from './ready-proposal-fixture.ts'; import { nativeSubscriberInputs, recordNativeSubscriberInputs, expectNativeSubscriberGraph } from './native-subscriber-inputs.ts'; -import { verifyNativeProposalReconciliation } from './proposal-reconciliation-fixture.ts'; +import { verifyNativeProposalReconciliation, verifyScopedInboxQuestionGates } from './proposal-reconciliation-fixture.ts'; import { createHash, generateKeyPairSync, randomUUID } from 'node:crypto'; import { stringify } from 'yaml'; import { validatePortableContentData } from '@treeseed/sdk/content-validation'; import { parseFrontmatterDocument } from '../../../../../../src/api/content/frontmatter.ts'; import { relationAuthoringDatabase, object } from '../../../knowledge/workspaces/architecture/relation-authoring-fixture.ts'; import { TreeDxInfrastructureClient } from '../../../../../../src/api/control-plane/treedx/infrastructure-client.ts'; -import { completedGraphRefresh } from '../../../../../../src/operations-runner/knowledge/publication-executor.ts'; +import { completedGraphRefresh } from '../../../../../../src/api/knowledge/gateway-treedx-connection.ts'; import { resolveKnowledgeGatewayConnection } from '../../../../../../src/api/knowledge/gateway-treedx-connection.ts'; import assert from 'node:assert/strict'; import { decodeExecutionNode } from '../../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-storage.ts'; @@ -481,8 +481,8 @@ describe('native proposal Decision authority', () => { expect(await f.store.all('SELECT * FROM capacity_operation_receipts ORDER BY id')).toEqual(allReceipts); const readback = object(await native.readRepositoryFile({ repoId: repository, ref: retainedContent.commit, path: retainedContent.path, encoding: 'utf8', parseFrontmatter: false, allowProtected: true })); expect(readback.resolvedRef).toBe(retainedContent.commit); expect(object(readback.file).content).toBe(retainedContent.raw); - // Native admission/claim does not prove live model execution or usage. } + if (!canonicalSupply) await verifyScopedInboxQuestionGates(f, declaration.id, decisionId); // No live model execution or usage is proved by native admission/claim. } finally { const cleanupErrors: unknown[] = []; for (const workspace of extraWorkspaces) try { await f.client.workspaces.close(workspace); } catch (error) { cleanupErrors.push(error); } diff --git a/tests/unit/control-plane/governance/proposals/architecture/proposal-reconciliation-fixture.ts b/tests/unit/control-plane/governance/proposals/architecture/proposal-reconciliation-fixture.ts index ac55c7c3e..2ccd98135 100644 --- a/tests/unit/control-plane/governance/proposals/architecture/proposal-reconciliation-fixture.ts +++ b/tests/unit/control-plane/governance/proposals/architecture/proposal-reconciliation-fixture.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +export { verifyScopedInboxQuestionGates } from '../../../knowledge/inbox/question-gate-native-contract.ts'; import { graphRevisionSchema } from '@treeseed/sdk/agent-capacity'; import type { CapacityGovernanceDatabase } from '../../../../../../src/api/capacity/database.ts'; import { reconcileExecutionGraph } from '../../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts'; diff --git a/tests/unit/control-plane/knowledge/inbox/answer-provenance.test.ts b/tests/unit/control-plane/knowledge/inbox/answer-provenance.test.ts new file mode 100644 index 000000000..f6c664371 --- /dev/null +++ b/tests/unit/control-plane/knowledge/inbox/answer-provenance.test.ts @@ -0,0 +1,39 @@ +import { createHash } from 'node:crypto'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { serializeFrontmatterDocument } from '../../../../../src/api/content/frontmatter.ts'; +import { readInboxQuestionAnswer } from '../../../../../src/api/control-plane/inbox/question-content.ts'; +import { resolveKnowledgeGatewayConnection } from '../../../../../src/api/knowledge/gateway-treedx-connection.ts'; + +vi.mock('../../../../../src/api/knowledge/gateway-treedx-connection.ts', async original => ({ + ...await original(), resolveKnowledgeGatewayConnection: vi.fn(), +})); +const input = { store: { getProjectTreeDxLibrary: async () => ({ contentPath: '.' }) }, projectId: 'project', + discussionId: 'handoff', path: 'discussion-messages/handoff/answer.mdx', commit: 'a'.repeat(40), authorId: 'coordinator' }; +const metadata = { schemaVersion: 'treeseed.discussion-message/v1', id: 'answer', discussionId: 'handoff', inboxIntent: 'answer', authorId: 'coordinator' }; +let source: string, resolvedRef: string, path: string; +beforeEach(() => { + vi.clearAllMocks(); source = serializeFrontmatterDocument(metadata, 'Reviewed transport adopted.'); resolvedRef = input.commit; path = input.path; + vi.mocked(resolveKnowledgeGatewayConnection).mockResolvedValue({ repositoryId: 'library', + client: { readRepositoryFiles: async () => ({ resolvedRef, files: [{ path, content: source }] }) }, + } as unknown as NonNullable>>); +}); +it('uses full immutable answer bytes and canonical exact-reference fields', async () => { + expect(await readInboxQuestionAnswer(input)).toEqual({ store: 'treedx', model: 'discussion-message', id: 'answer', revision: 1, + digest: `sha256:${createHash('sha256').update(source).digest('hex')}`, repository: 'library', path, commit: input.commit }); + expect(vi.mocked(resolveKnowledgeGatewayConnection).mock.calls[0]?.[1]).toEqual({ projectId: 'project', write: false, + readRefs: [input.commit], workspacePaths: [input.path] }); +}); +it.each([{ inboxIntent: 'comment' }, { discussionId: 'other' }, { authorId: 'other' }, { schemaVersion: 'other' }])('refuses non-answer or mismatched message authority: %j', patch => { + source = serializeFrontmatterDocument({ ...metadata, ...patch }, 'Reviewed transport adopted.'); + return expect(readInboxQuestionAnswer(input)).rejects.toMatchObject({ code: 'inbox_answer_readback_failed' }); +}); +it.each(['moved', 'wrong-path', 'empty'] as const)('refuses %s readback', fault => { + if (fault === 'moved') resolvedRef = 'b'.repeat(40); + if (fault === 'wrong-path') path = 'discussion-messages/handoff/other.mdx'; + if (fault === 'empty') source = serializeFrontmatterDocument(metadata, ''); + return expect(readInboxQuestionAnswer(input)).rejects.toMatchObject({ code: 'inbox_answer_readback_failed' }); +}); +it.each([{ commit: 'refs/heads/staging' }, { path: 'notes/answer.mdx' }, { path: 'discussion-messages/other/answer.mdx' }])('denies invalid authority before acquiring a scoped reader: %j', patch => { + return expect(readInboxQuestionAnswer({ ...input, ...patch })).rejects.toMatchObject({ code: 'inbox_answer_readback_failed' }) + .then(() => expect(resolveKnowledgeGatewayConnection).not.toHaveBeenCalled()); +}); diff --git a/tests/unit/control-plane/knowledge/inbox/question-gate-native-contract.ts b/tests/unit/control-plane/knowledge/inbox/question-gate-native-contract.ts new file mode 100644 index 000000000..3f714a11a --- /dev/null +++ b/tests/unit/control-plane/knowledge/inbox/question-gate-native-contract.ts @@ -0,0 +1,81 @@ +import { createHash } from 'node:crypto'; +import { expect } from 'vitest'; +import type { relationAuthoringDatabase } from '../workspaces/architecture/relation-authoring-fixture.ts'; +import { createInboxService } from '../../../../../src/api/control-plane/inbox/inbox-service.ts'; +import { createDiscussionService } from '../../../../../src/api/discussions/discussion-service.ts'; +import { commitDiscussionMessage } from '../../../../../src/api/discussions/content.ts'; +import { createGovernanceService } from '../../../../../src/api/control-plane/governance/governance-service.ts'; +import { SessionEventService } from '../../../../../src/api/realtime/session-events.ts'; +import { decodeExecutionNode, decodeExecutionEdge } from '../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-storage.ts'; +import { resolveKnowledgeGatewayConnection } from '../../../../../src/api/knowledge/gateway-treedx-connection.ts'; + +/** Extends the existing native governance case AFTER original Decision creation + * and replay checks. No accepted Decision, graph condition or answer receipt is + * inserted by this helper. Controlled agent messages are not managed execution. */ +export async function verifyScopedInboxQuestionGates(f: Awaited>, proposalId: string, decisionId: string) { + const projectId = f.sources[0]!.projectId; + const discussions = createDiscussionService({ store: f.store, capacity: f.store, sessionEvents: new SessionEventService(f.store) }); + const dependencies = { store: f.store, discussions, governance: createGovernanceService(f.store, discussions), + communications: { send: async () => { throw new Error('Coordinator prerequisite has no addressed recipients'); } } }; + const inbox = createInboxService(dependencies); + const graph = async () => ({ nodes: (await f.store.all('SELECT * FROM execution_nodes WHERE team_id=? ORDER BY id', ['team'])).map(decodeExecutionNode), + edges: (await f.store.all('SELECT * FROM execution_edges WHERE team_id=? ORDER BY id', ['team'])).map(decodeExecutionEdge) }); + const before = await graph(), originalAuthority = await f.store.all('SELECT * FROM governance_decisions ORDER BY id'); + const body = { projectId, title: 'Adopt reviewed transport before final integration', markdown: 'Record exact runtime adoption evidence.', severity: 'blocking', + relatedProposals: [proposalId], relatedDecisions: [decisionId], relatedWorkItems: ['peer-contribution'] }; + const created = await inbox.createQuestion(f.principal, 'team', body, 'native-runtime-handoff'); + expect((await inbox.createQuestion(f.principal, 'team', body, 'native-runtime-handoff')).item.id).toBe(created.item.id); + const gate = (await graph()).nodes.find(node => node.condition?.subjectRef.model === 'question' && node.condition.subjectRef.id === created.item.id); + expect(gate).toMatchObject({ status: 'blocked', condition: { subjectRef: { model: 'question', commit: created.item.provenance.commitSha } } }); + const targets = (await graph()).edges.filter(edge => edge.fromNodeId === gate!.id).map(edge => (before.nodes).find(node => node.id === edge.toNodeId)); + expect(targets).toMatchObject([{ workItemId: 'peer-contribution', pairRole: 'actor' }]); + const unrelated = before.nodes.find(node => node.sourceRef.id === proposalId && node.pairRole === 'actor' && node.workItemId !== 'peer-contribution')!; + expect((await graph()).nodes.find(node => node.id === unrelated.id)?.status).toBe(unrelated.status); + const commented = await inbox.action(f.principal, 'team', created.item.id, { action: 'comment', markdown: 'Transport adoption is still in progress.' }, 'native-handoff-comment', created.item.etag); + const message = await commitDiscussionMessage({ store: f.store, teamId: 'team', projectId, discussionId: commented.item.discussionId!, + principal: { id: 'controlled-agent' }, authorType: 'agent', intent: 'discuss', inboxIntent: 'comment', body: 'Investigating the runtime.', messageId: 'agent-comment' }); + expect(message.commitSha).toMatch(/^[a-f0-9]{40}$/u); + expect((await inbox.show(f.principal, 'team', created.item.id)).status).toBe('outstanding'); + const answered = await inbox.action(f.principal, 'team', created.item.id, { action: 'answer', markdown: 'The exact reviewed transport is adopted; native origin readback is recorded.' }, 'native-handoff-answer', commented.item.etag); + expect(answered.item.status).toBe('answered'); + const row = await f.store.first("SELECT payload_json FROM inbox_events WHERE item_id=? AND event_type='question.answered' ORDER BY sequence DESC LIMIT 1", [created.item.id]); + const proof = typeof row!.payload_json === 'string' ? JSON.parse(row!.payload_json) : row!.payload_json; + const ref = proof.answerRef; + expect(ref).toMatchObject({ store: 'treedx', model: 'discussion-message', repository: f.sources[0]!.repository }); + const reader = await resolveKnowledgeGatewayConnection(f.store, { projectId, write: false, readRefs: [ref.commit], workspacePaths: [ref.path] }); + if (!reader) throw new Error('Native exact answer reader required'); + const read = await reader.client.readRepositoryFiles({ repoId: ref.repository, ref: ref.commit, paths: [ref.path], encoding: 'utf8', parseFrontmatter: false, allowProtected: true }); + expect(read.resolvedRef).toBe(ref.commit); expect(`sha256:${createHash('sha256').update(read.files[0].content).digest('hex')}`).toBe(ref.digest); + expect((await graph()).nodes.find(node => node.id === gate!.id)).toMatchObject({ status: 'completed', authorityRefs: [ref] }); + let resume!: () => void, entered!: () => void; + const paused = new Promise(resolve => { resume = resolve; }), writing = new Promise(resolve => { entered = resolve; }); + const lateInbox = createInboxService({ ...dependencies, discussions: { ...discussions, create: async (...args: Parameters) => { + const actual = await discussions.create(...args); entered(); await paused; return actual; + } } }); + const lateAnswer = lateInbox.action(f.principal, 'team', created.item.id, { action: 'answer', markdown: 'An answer started before reopen must not release it.' }, 'native-handoff-late-answer', answered.item.etag); + await Promise.race([writing, lateAnswer.then(() => { throw new Error('Late answer completed before its controlled response boundary'); })]); + const reopened = await inbox.action(f.principal, 'team', created.item.id, { action: 'reopen' }, 'native-handoff-reopen', answered.item.etag); + resume(); await expect(lateAnswer).rejects.toMatchObject({ status: 412, code: 'inbox_item_precondition_failed' }); + expect(reopened.item.status).toBe('outstanding'); + expect((await graph()).nodes.find(node => node.id === gate!.id)).toMatchObject({ status: 'blocked', authorityRefs: [] }); + const reread = await inbox.show(f.principal, 'team', created.item.id); expect(reread.status).toBe('outstanding'); + await commitDiscussionMessage({ store: f.store, teamId: 'team', projectId, discussionId: commented.item.discussionId!, + principal: { id: 'controlled-agent' }, authorType: 'agent', intent: 'discuss', inboxIntent: 'answer', + body: 'Controlled explicit answer for native lifecycle coverage; no managed execution claimed.', messageId: 'agent-answer' }); + const agentEvidence = await inbox.show(f.principal, 'team', created.item.id); expect(agentEvidence.status).toBe('outstanding'); + expect((await graph()).nodes.find(node => node.id === gate!.id)?.status).toBe('blocked'); + const acceptedEvidence = await inbox.action(f.principal, 'team', created.item.id, { action: 'answer', markdown: 'Accept the exact submitted evidence after coordinator evaluation.' }, 'native-handoff-accept-evidence', agentEvidence.etag); + expect((await graph()).nodes.find(node => node.id === gate!.id)?.status).toBe('completed'); + await inbox.action(f.principal, 'team', created.item.id, { action: 'reopen' }, 'native-handoff-reopen-again', acceptedEvidence.item.etag); + expect((await inbox.show(f.principal, 'team', created.item.id)).status).toBe('outstanding'); + expect((await graph()).nodes.find(node => node.id === gate!.id)).toMatchObject({ status: 'blocked', authorityRefs: [] }); + const advisory = await inbox.createQuestion(f.principal, 'team', { ...body, severity: 'advisory', title: 'Explain integration preparation' }, 'native-advisory-question'); + const routed = await inbox.action(f.principal, 'team', advisory.item.id, { action: 'comment', markdown: 'Supply the explanation here.' }, 'native-advisory-topic', advisory.item.etag); + await commitDiscussionMessage({ store: f.store, teamId: 'team', projectId, discussionId: routed.item.discussionId!, + principal: { id: 'controlled-agent' }, authorType: 'agent', intent: 'discuss', inboxIntent: 'answer', + body: 'Controlled advisory answer for the native inbox lifecycle.', messageId: 'advisory-answer' }); + const explained = await inbox.show(f.principal, 'team', advisory.item.id); expect(explained.status).toBe('answered'); + await inbox.action(f.principal, 'team', advisory.item.id, { action: 'reopen' }, 'native-advisory-reopen', explained.etag); + expect((await inbox.show(f.principal, 'team', advisory.item.id)).status).toBe('outstanding'); + expect(await f.store.all('SELECT * FROM governance_decisions ORDER BY id')).toEqual(originalAuthority); +} diff --git a/tests/unit/control-plane/knowledge/inbox/question-scope.test.ts b/tests/unit/control-plane/knowledge/inbox/question-scope.test.ts new file mode 100644 index 000000000..fd9291fd4 --- /dev/null +++ b/tests/unit/control-plane/knowledge/inbox/question-scope.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { commitInboxQuestion } from '../../../../../src/api/control-plane/inbox/question-content.ts'; +import { resolveKnowledgeGatewayConnection } from '../../../../../src/api/knowledge/gateway-treedx-connection.ts'; +import { applyTextChangeset } from '../../../../../src/api/knowledge/changesets/apply-text-changeset.ts'; +import { recordTreeDxAuthoringState } from '../../../../../src/api/capacity/services/treedx/repositories/treedx-authoring-journal.ts'; + +vi.mock('../../../../../src/api/knowledge/gateway-treedx-connection.ts', async original => ({ + ...await original(), resolveKnowledgeGatewayConnection: vi.fn(), +})); +vi.mock('../../../../../src/api/knowledge/changesets/apply-text-changeset.ts', () => ({ applyTextChangeset: vi.fn() })); +vi.mock('../../../../../src/api/capacity/services/treedx/repositories/treedx-authoring-journal.ts', () => ({ + recordTreeDxAuthoringState: vi.fn(), listOpenTreeDxWorkspaces: async () => [], recordTreeDxWorkspaceState: vi.fn(), +})); + +const commit = 'a'.repeat(40); +let readback: 'exact' | 'moved' | 'wrong-path' | 'changed-bytes' | 'missing' = 'exact'; +const closeWorkspace = vi.fn(async () => undefined); +const input = () => ({ store: { getProjectTreeDxLibrary: vi.fn(async (): Promise<{ contentPath: string } | null> => ({ contentPath: '.' })) }, + teamId: 'team', projectId: 'deployment', principal: { id: 'coordinator' }, + title: 'Activate reviewed origin transport', markdown: 'Adopt the reviewed transport before Releaser integration.', + severity: 'blocking' as const, relatedObjectives: [], relatedProposals: ['origin-proposal'], + relatedDecisions: ['origin-decision'], relatedWorkItems: ['simulated-origin-delivery'] }); + +beforeEach(() => { + vi.clearAllMocks(); + readback = 'exact'; + vi.mocked(resolveKnowledgeGatewayConnection).mockImplementation(async (_store, scope) => ({ + contentPath: '.', repositoryId: 'library', baseRef: 'b'.repeat(40), authoringBranch: 'staging', + client: { closeWorkspace, + createWorkspace: async ({ allowedPaths = ['**'], mode = 'read_only', workspaceId = 'workspace' }: { allowedPaths?: string[]; mode?: string; workspaceId?: string }) => { + if (allowedPaths.length !== 1 || !allowedPaths.every(path => scope.workspacePaths?.includes(path))) throw new Error('Permission denied.'); + if (mode !== 'writable') throw new Error('Permission denied: files:write.'); + return { workspaceId, baseCommitSha: 'b'.repeat(40), baseRef: 'refs/heads/staging' }; + }, + commit: async () => ({ commitSha: commit, branchName: 'refs/heads/staging', changedPaths: [] }), + readRepositoryFiles: async ({ paths }: { paths: string[] }) => { + if (!paths.every(path => scope.workspacePaths?.includes(path))) throw new Error('Permission denied.'); + const change = vi.mocked(applyTextChangeset).mock.calls[0]?.[0].changes[0]; + return { resolvedRef: readback === 'moved' ? 'c'.repeat(40) : commit, + files: readback === 'missing' ? [] : [{ path: readback === 'wrong-path' ? 'questions/other.mdx' : paths[0], + content: readback === 'changed-bytes' ? `${change?.after}\n` : change?.after }] }; + }, + }, + } as unknown as NonNullable>>)); + vi.mocked(applyTextChangeset).mockImplementation(async ({ changes }) => { + const writeScope = vi.mocked(resolveKnowledgeGatewayConnection).mock.calls.find(([, scope]) => scope.write)?.[1]; + if (!changes.every(change => writeScope?.workspacePaths?.includes(change.path))) throw new Error('Permission denied.'); + }); +}); + +it.each(['moved', 'wrong-path', 'changed-bytes', 'missing'] as const)('retains failure and closes the workspace for %s readback', async value => { + readback = value; + await expect(commitInboxQuestion(input())).rejects.toMatchObject({ code: 'inbox_question_readback_failed' }); + expect(recordTreeDxAuthoringState).not.toHaveBeenCalled(); expect(closeWorkspace).toHaveBeenCalledWith(expect.stringMatching(/^ws_/u)); +}); + +it.each(['.', 'portfolio/library'])('authors and reads the exact question with narrow scope under %s', async contentPath => { + const request = input(); request.store.getProjectTreeDxLibrary.mockResolvedValue({ contentPath }); + const result = await commitInboxQuestion(request); + expect(result.path).toMatch(new RegExp(`^${contentPath === '.' ? '' : `${contentPath}/`}questions/activate-reviewed-origin-transport-[a-f0-9]{8}\\.mdx$`, 'u')); + expect(result.source).toContain('simulated-origin-delivery'); + for (const [, scope] of vi.mocked(resolveKnowledgeGatewayConnection).mock.calls) { + expect(scope.workspacePaths).toEqual([result.path]); expect(scope.relationPaths).toBeUndefined(); + } + expect(vi.mocked(resolveKnowledgeGatewayConnection).mock.calls.at(-1)?.[1].readRefs).toEqual([commit]); + expect(recordTreeDxAuthoringState).toHaveBeenCalledOnce(); expect(closeWorkspace).toHaveBeenCalledWith(expect.stringMatching(/^ws_/u)); +}); + +it('does not acquire authoring authority when the project library is unavailable', async () => { + const request = input(); request.store.getProjectTreeDxLibrary.mockResolvedValue(null); + await expect(commitInboxQuestion(request)).rejects.toMatchObject({ code: 'inbox_treedx_unavailable' }); + expect(resolveKnowledgeGatewayConnection).not.toHaveBeenCalled(); expect(applyTextChangeset).not.toHaveBeenCalled(); +}); + +it('rejects an unsafe library root before acquiring authoring authority', async () => { + const request = input(); request.store.getProjectTreeDxLibrary.mockResolvedValue({ contentPath: '../other' }); + await expect(commitInboxQuestion(request)).rejects.toThrow('unsafe'); + expect(resolveKnowledgeGatewayConnection).not.toHaveBeenCalled(); +}); diff --git a/tests/unit/control-plane/knowledge/inbox/question-transition.test.ts b/tests/unit/control-plane/knowledge/inbox/question-transition.test.ts new file mode 100644 index 000000000..3a6411d41 --- /dev/null +++ b/tests/unit/control-plane/knowledge/inbox/question-transition.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { PGlite } from '@electric-sql/pglite'; +import { expect, it, vi } from 'vitest'; +import { createInboxService } from '../../../../../src/api/control-plane/inbox/inbox-service.ts'; +import { executePostgresBatch, translateControlPlaneSqlToPostgres } from '../../../../../src/api/support/control-plane-postgres.ts'; +import { splitPostgresSqlStatements } from '../../../../../src/api/persistence/postgres-sql-statements.ts'; +vi.mock('../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts', () => ({ reconcileExecutionGraph: vi.fn() })); +vi.mock('../../../../../src/api/discussions/content.ts', () => ({ loadDiscussions: async () => ({ messages: [] }) })); +vi.mock('../../../../../src/api/control-plane/inbox/question-content.ts', async original => ({ + ...await original(), readInboxQuestionAnswer: async () => ({ store: 'treedx', model: 'discussion-message', id: 'answer', revision: 1, + digest: `sha256:${'b'.repeat(64)}`, repository: 'library', path: 'discussion-messages/topic/answer.mdx', commit: 'b'.repeat(40) }), +})); + +// SQL storage unit boundary: original DDL, SQL translation and batch executor. +// Discussion/readback are controlled dependencies; native TreeDX and concurrent +// PostgreSQL are proved separately in the existing native governance case. +it.each(['normal', 'reopened', 'source-edited'] as const)('commits exact lifecycle proof only against unchanged question authority: %s', async mode => { + const db = new PGlite(); + try { + const ddl = splitPostgresSqlStatements(readFileSync('drizzle/control-plane/0000_control_plane.sql', 'utf8')); + for (const table of ['teams', 'projects']) { + const statement = ddl.find(sql => sql.startsWith(`CREATE TABLE "${table}" (`)); if (!statement) throw new Error('Original owner DDL required'); await db.exec(statement); + } + await db.exec(readFileSync('drizzle/control-plane/0005_governance_inbox.sql', 'utf8')); + const query = (sql: string, params: unknown[] = []) => db.query>(translateControlPlaneSqlToPostgres(sql), params); + const now = '2026-10-25T01:00:00.000Z'; + await query('INSERT INTO teams (id,slug,name,created_at,updated_at) VALUES (?,?,?,?,?)', ['team','team','Team',now,now]); + await query('INSERT INTO projects (id,team_id,slug,name,created_at,updated_at) VALUES (?,?,?,?,?,?)', ['project','team','project','Project',now,now]); + await query(`INSERT INTO inbox_items (id,team_id,project_id,kind,title,markdown,status,author_id,author_label,discussion_id, + repository_id,content_path,commit_sha,digest,created_at,updated_at) VALUES (?,?,?,'question',?,?,'outstanding',?,?,?,?,?,?,?,?,?)`, + ['handoff','team','project','Reviewed runtime handoff','Keep integration held.','coordinator','Coordinator','topic','library','questions/handoff.mdx','a'.repeat(40),'a'.repeat(64),now,now]); + const store = { run: query, first: async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0] ?? null, + all: async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows, + batch: (operations: Parameters[1]) => db.transaction(tx => executePostgresBatch(tx as unknown as Parameters[0], operations)), + getProjectDetails: async () => ({ project: { slug: 'project' } }) }; + let intervene: (() => Promise) | undefined; + const discussions = { create: async () => { await intervene?.(); return { discussion: { id: 'topic' }, message: { path: 'discussion-messages/topic/answer.mdx' }, commitSha: 'b'.repeat(40) }; } }; + const inbox = createInboxService({ store, discussions, communications: {}, governance: {} }); + const principal = { id: 'coordinator', roles: ['admin'] }; + const view = () => store.first('SELECT * FROM inbox_items WHERE id=?', ['handoff']); + const tag = async () => { const row = (await view())!; return `inbox:${row.id}:${row.version}:${new Date(String(row.updated_at)).toISOString()}`; }; + const first = await inbox.action(principal,'team','handoff',{action:'answer',markdown:'Reviewed input.'},'first-answer',await tag()); + expect(first.item.status).toBe('answered'); + const suppliedTag = first.item.etag; + if (mode === 'reopened') intervene = async () => { + intervene = undefined; await inbox.action(principal,'team','handoff',{action:'reopen'},'reopen',suppliedTag); + }; + if (mode === 'source-edited') intervene = async () => { + intervene = undefined; await query('UPDATE inbox_items SET commit_sha=?,digest=? WHERE id=?', ['c'.repeat(40),'c'.repeat(64),'handoff']); + }; + const answer = inbox.action(principal,'team','handoff',{action:'answer',markdown:'Late evaluated input.'},'later-answer',suppliedTag); + if (mode === 'normal') await expect(answer).resolves.toMatchObject({ item: { status: 'answered' } }); + else await expect(answer).rejects.toMatchObject({ status: 412, code: 'inbox_item_precondition_failed' }); + const events = await store.all("SELECT event_type,payload_json FROM inbox_events WHERE event_type='question.answered' ORDER BY sequence"); + expect(events).toHaveLength(mode === 'normal' ? 2 : 1); + expect(events[0]?.payload_json).toMatchObject({ questionRef: { model: 'question', commit: 'a'.repeat(40) }, answerRef: { model: 'discussion-message', commit: 'b'.repeat(40) } }); + if (mode === 'reopened') expect((await view())?.status).toBe('outstanding'); + } finally { await db.close(); } +}); diff --git a/tests/unit/control-plane/knowledge/knowledge-publication-graph-refresh.test.ts b/tests/unit/control-plane/knowledge/knowledge-publication-graph-refresh.test.ts index 31497f460..4fdbcf595 100644 --- a/tests/unit/control-plane/knowledge/knowledge-publication-graph-refresh.test.ts +++ b/tests/unit/control-plane/knowledge/knowledge-publication-graph-refresh.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { completedGraphRefresh, treeDxResult } from '../../../../src/operations-runner/knowledge/publication-executor.ts'; +import { completedGraphRefresh, treeDxResult } from '../../../../src/api/knowledge/gateway-treedx-connection.ts'; describe('knowledge publication graph refresh', () => { it('unwraps TreeDX graph and refresh-job envelopes', async () => { @@ -17,4 +17,15 @@ describe('knowledge publication graph refresh', () => { expect(treeDxResult({ index: { resolvedRef: 'abc', stale: false } }, 'index')) .toEqual({ resolvedRef: 'abc', stale: false }); }); + it('preserves the existing bounded refresh wait and fails without a completed job', async () => { + vi.useFakeTimers(); + try { + const client = { refreshGraph: vi.fn(async () => ({ graph: { jobId: 'pending' } })), + getGraphRefreshJob: vi.fn(async () => ({ job: { status: 'running' } })) }; + const checked = expect(completedGraphRefresh(client, { repoId: 'repo', ref: 'a'.repeat(40) })) + .rejects.toThrow('did not complete before the publication deadline'); + await vi.runAllTimersAsync(); await checked; + expect(client.getGraphRefreshJob).toHaveBeenCalledTimes(60); + } finally { vi.useRealTimers(); } + }); }); diff --git a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-authority.test.ts b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-authority.test.ts index ea415aabe..b1d6cd912 100644 --- a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-authority.test.ts +++ b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-authority.test.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from 'vitest'; import { validateContentFrontmatter } from '@treeseed/sdk/content-validation'; import { applyTextChangeset } from '../../../../../../src/api/knowledge/changesets/apply-text-changeset.ts'; import { createUnifiedChangeset } from '../../../../../../src/api/knowledge/changesets/unified-diff.ts'; -import { completedGraphRefresh, requireIndexedSourceClosure } from '../../../../../../src/operations-runner/knowledge/publication-executor.ts'; +import { requireIndexedSourceClosure } from '../../../../../../src/operations-runner/knowledge/publication-executor.ts'; +import { completedGraphRefresh } from '../../../../../../src/api/knowledge/gateway-treedx-connection.ts'; import { relationInputs, relationPath } from '../../../capacity/execution/graph/architecture/relations/relation-fixture.ts'; import { noteSource } from './relation-authoring-fixture.ts'; diff --git a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-fixture.ts b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-fixture.ts index a5e47431b..94aec0143 100644 --- a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-fixture.ts +++ b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-fixture.ts @@ -8,7 +8,8 @@ import { ControlPlaneStore } from '../../../../../../src/api/persistence/store.t import { TreeDxInfrastructureClient } from '../../../../../../src/api/control-plane/treedx/infrastructure-client.ts'; import { createKnowledgeWorkspaceService } from '../../../../../../src/api/control-plane/knowledge/knowledge-workspace-service.ts'; import { resolveKnowledgeGatewayConnection } from '../../../../../../src/api/knowledge/gateway-treedx-connection.ts'; -import { completedGraphRefresh, requireIndexedSourceClosure, treeDxResult } from '../../../../../../src/operations-runner/knowledge/publication-executor.ts'; +import { requireIndexedSourceClosure } from '../../../../../../src/operations-runner/knowledge/publication-executor.ts'; +import { completedGraphRefresh, treeDxResult } from '../../../../../../src/api/knowledge/gateway-treedx-connection.ts'; import { loadTeamExactDependencyLinks } from '../../../../../../src/api/capacity/services/capacity/execution/exact-dependency-links.ts'; import { relationInputs, relationPath } from '../../../capacity/execution/graph/architecture/relations/relation-fixture.ts'; import { postgresGraph } from '../../../capacity/execution/graph/architecture/living/living-postgres-fixture.ts'; @@ -171,10 +172,8 @@ export async function relationAuthoringDatabase(nativePostgres = false) { assert.equal(file.resolvedRef, commit); const nativeFile = object(file.file ?? (Array.isArray(file.files) ? file.files[0] : undefined)); assert.equal(nativeFile.path, relationPath); assert.equal(nativeFile.content, content); await completedGraphRefresh(read.client, { repoId: read.repositoryId, ref: read.publicationRef, paths: ['notes/**'] }); - // Loader queries every selected project's authoritative binding. Build the - // original native index for the other disposable selected library too. - const secondary = await resolveKnowledgeGatewayConnection(store, { projectId: 'dependent', write: true }); assert.ok(secondary); - await completedGraphRefresh(secondary.client, { repoId: secondary.repositoryId, ref: secondary.baseRef, paths: ['notes/**'] }); + // The fresh secondary library has no graph. The owning loader must obtain + // its exact index through the public grant/refresh/job/query path itself. const loaded = await loadTeamExactDependencyLinks(store, input.sources); return { commit, graph, search, file, loaded, read }; }; diff --git a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-native.test.ts b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-native.test.ts index 6f5598df2..d7d906e9e 100644 --- a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-native.test.ts +++ b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-authoring-native.test.ts @@ -7,12 +7,41 @@ import { managedTeamLibrarySeedFiles } from '../../../../../../src/api/teams/man import { parseBook } from '../../../../../../src/api/knowledge/runtime/catalog.ts'; import { resolveKnowledgeGatewayConnection } from '../../../../../../src/api/knowledge/gateway-treedx-connection.ts'; import { relationPath } from '../../../capacity/execution/graph/architecture/relations/relation-fixture.ts'; +import { commitInboxQuestion } from '../../../../../../src/api/control-plane/inbox/question-content.ts'; // Real native TreeDX workspace/Git/graph/search plus original API store SQL and // owning knowledge service. No controlled HTTP substitute, no missing-env pass. // This is not a managed provider run, authenticated operator HTTP or whole // publication-runner/portfolio acceptance. All resources created inside tests. describe('native ordinary relation creation and indexing', () => { + it('real native inbox question authoring grants only its question path and retains exact Git bytes and relations', async () => { + const f = await relationAuthoringDatabase(); try { + const before = await f.snapshot(); + const authored = await commitInboxQuestion({ store: f.store, principal: f.principal, teamId: 'team', projectId: 'precursor', + title: 'Activate reviewed simulation transport', markdown: 'Adopt the reviewed local origin before integration.', severity: 'blocking', + relatedObjectives: [], relatedProposals: ['origin-proposal'], relatedDecisions: ['origin-decision'], + relatedWorkItems: ['simulated-origin-delivery'] }); + const scoped = await resolveKnowledgeGatewayConnection(f.store, { projectId: 'precursor', write: false, + readRefs: [authored.commitSha], workspacePaths: [authored.path] }); + if (!scoped) throw new Error('Native exact question scope required'); + const observed = await scoped.client.readRepositoryFiles({ repoId: authored.repositoryId, ref: authored.commitSha, + paths: [authored.path], encoding: 'utf8', parseFrontmatter: false, allowProtected: true }); + expect(observed.resolvedRef).toBe(authored.commitSha); + expect(observed.files).toHaveLength(1); expect(observed.files[0]).toMatchObject({ path: authored.path, content: authored.source }); + expect(createHash('sha256').update(observed.files[0]!.content).digest('hex')).toBe(authored.digest); + expect(authored.source).toContain('simulated-origin-delivery'); + expect(scoped.allowedPaths.filter(path => !['books/**', 'knowledge/**', 'assets/**'].includes(path))).toEqual([authored.path]); + for (const denied of ['questions/another.mdx', 'notes/transport.mdx', 'agents/releaser.yaml']) { + await expect(scoped.client.readRepositoryFiles({ repoId: authored.repositoryId, ref: authored.commitSha, + paths: [denied], encoding: 'utf8', parseFrontmatter: false, allowProtected: true })).rejects.toThrow(); + } + const binding = await f.store.getProjectTreeDxLibrary('precursor'); expect(binding?.contentRepositoryRef).toBe(authored.commitSha); + const after = await f.snapshot(); expect(after.ledger).toEqual(before.ledger); + expect(after.reviews).toEqual(before.reviews); expect(after.publications).toEqual(before.publications); + // Authoring/readback boundary only. No provider execution, answered + // question, graph gate, approval or release is asserted by this case. + } finally { await f.close(); } + }, 60_000); it('real native Book metadata authoring retains exact legacy and current body bytes without publication or duplicate writes', async () => { const f = await relationAuthoringDatabase(); try { for (const legacy of [true, false]) { diff --git a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-publication-fixture.ts b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-publication-fixture.ts index a90b75a7a..20f0f6353 100644 --- a/tests/unit/control-plane/knowledge/workspaces/architecture/relation-publication-fixture.ts +++ b/tests/unit/control-plane/knowledge/workspaces/architecture/relation-publication-fixture.ts @@ -4,11 +4,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { relationAuthoringDatabase } from './relation-authoring-fixture.ts'; import { createLocalKnowledgePublicationStorage } from '../../../../../../src/api/knowledge/publication-storage.ts'; -import { createKnowledgePublicationExecutor, completedGraphRefresh } from '../../../../../../src/operations-runner/knowledge/publication-executor.ts'; +import { createKnowledgePublicationExecutor } from '../../../../../../src/operations-runner/knowledge/publication-executor.ts'; import { DirectControlPlaneRunnerClient } from '../../../../../../src/operations-runner/client/direct-control-plane-runner-client.ts'; import { runPlatformOperationOnce } from '../../../../../../src/operations-runner/entrypoint-support/operations/operation-execution.ts'; import { loadTeamExactDependencyLinks } from '../../../../../../src/api/capacity/services/capacity/execution/exact-dependency-links.ts'; -import { resolveKnowledgeGatewayConnection } from '../../../../../../src/api/knowledge/gateway-treedx-connection.ts'; // Same native fixture, official SDK, original SQL and existing local runner/storage. // Only these fresh managed repositories are in scope. No external publication, @@ -32,10 +31,8 @@ export async function relationPublicationDatabase() { { version: workspace.version, message: 'Publish exact native dependency Note' }); const run = (operationId: string) => runPlatformOperationOnce({ client, runnerId, operationId, executors: [executor], environment: 'local' }); const load = async () => { - // The unchanged selected secondary library also needs its real empty index; - // never supply a graph response to the owning loader. - const secondary = await resolveKnowledgeGatewayConnection(f.store, { projectId: 'dependent', write: true }); assert.ok(secondary); - await completedGraphRefresh(secondary.client, { repoId: secondary.repositoryId, ref: secondary.baseRef, paths: ['notes/**'] }); + // Do not pre-index the fresh secondary repository: ordinary dependency + // loading owns observation/recovery of its exact native graph readiness. return loadTeamExactDependencyLinks(f.store, f.sources); }; const close = async () => { diff --git a/tests/unit/control-plane/mcp/authorization-equivalence.test.ts b/tests/unit/control-plane/mcp/authorization-equivalence.test.ts index 138b152d9..b5b198ccd 100644 --- a/tests/unit/control-plane/mcp/authorization-equivalence.test.ts +++ b/tests/unit/control-plane/mcp/authorization-equivalence.test.ts @@ -7,6 +7,47 @@ import { installControlPlaneProtocolRoutes } from '../../../../src/api/control-p import { sdkOperationInputStandardSchema, sdkSchemaJson, sdkStandardSchema } from '../../../../src/api/control-plane/catalog/sdk-standard-schema.ts'; describe('MCP authorization equivalence', () => { + it.each(['legacy', '2026-07-28'] as const)('preserves the shared safe operation problem over native MCP %s', async (protocol) => { + const binding = CONTROL_PLANE_OPERATIONS.knowledge.updateWorkspaceContent; + const observed: OperationInvocationContext[] = []; + const failure = Object.assign(new ControlPlaneOperationError(403, 'treedx_read_denied', 'TreeDX Discussion operation failed.'), + { cause: new Error('private-cause-token'), details: { authorization: 'private-bearer' } }); + const app = new Hono(); + installControlPlaneProtocolRoutes(app, async () => ({ principal: { id: 'coordinator', scopes: ['treeseed:knowledge:write'] }, + credential: { id: 'coordinator-client' } }), undefined, new OperationRegistry([{ binding, async handler(_input, context) { + observed.push(context); throw failure; + } }])); + const client = new Client({ name: 'problem-coordinator', version: '1' }, { + versionNegotiation: { mode: protocol === 'legacy' ? 'legacy' : { pin: protocol } }, + }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + authProvider: { token: async () => 'controlled-operator' }, + fetch: (input, init) => { + const request = input instanceof Request ? input : new Request(input, init), headers = new Headers(request.headers); + headers.set('host', 'localhost'); return app.fetch(new Request(request, { headers })); + }, + })); + const headers = { 'Idempotency-Key': 'problem-operation', 'If-Match': 'revision-1' }; + const body = { kind: 'agent-profile', sourcePath: 'agents/engineer.yaml', version: 1, content: 'governed source' }; + try { + const rest = binding.descriptor.rest!; + const path = rest.path.replace('{workspaceId}', 'workspace-api'); + const response = await app.request(path, { method: rest.method, headers: { authorization: 'Bearer controlled-operator', + 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) }); + expect(response.status).toBe(403); + const httpProblem = await response.json(); + const result = await client.callTool({ name: binding.descriptor.operationId, + arguments: { path: { workspaceId: 'workspace-api' }, headers, body } }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ ...httpProblem, instance: `mcp:${binding.descriptor.operationId}`, + requestId: observed[1]!.requestId }); + expect(result.structuredContent).toEqual({ type: 'https://treeseed.dev/problems/treedx_read_denied', title: 'Access denied', + status: 403, code: 'treedx_read_denied', detail: 'TreeDX Discussion operation failed.', + instance: `mcp:${binding.descriptor.operationId}`, requestId: observed[1]!.requestId }); + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(result.structuredContent) }]); + expect(JSON.stringify(result)).not.toMatch(/private-cause-token|private-bearer|authorization|cause|details/u); + } finally { await client.close(); } + }); it('retains real MCP mutation identity and version headers and denies missing authority before dispatch', async () => { const binding = CONTROL_PLANE_OPERATIONS.knowledge.updateWorkspaceContent; const observations: OperationInvocationContext[] = []; diff --git a/tests/unit/control-plane/mcp/mutation-postgres.test.ts b/tests/unit/control-plane/mcp/mutation-postgres.test.ts index 8350acca9..947a65cb5 100644 --- a/tests/unit/control-plane/mcp/mutation-postgres.test.ts +++ b/tests/unit/control-plane/mcp/mutation-postgres.test.ts @@ -51,7 +51,14 @@ it('native MCP cancels only the inspected queued project discussion and retains const before = await rows(); for (const supplied of [{}, { 'Idempotency-Key': 'cancel-api-research' }, { 'Idempotency-Key': 'cancel-api-research', 'If-Match': 'stale' }]) { Object.assign(headers, supplied); - expect(await call()).toMatchObject({ isError: true }); expect(await rows()).toEqual(before); + const denied = await call(); + expect(denied).toMatchObject({ isError: true }); + if (supplied['If-Match']) expect(denied.structuredContent).toMatchObject({ + status: 412, code: 'agent_invocation_precondition_failed', + detail: 'The agent invocation changed after it was inspected.', + instance: 'mcp:communications.invocations.cancel', requestId: expect.any(String), + }); + expect(await rows()).toEqual(before); } delete headers['Idempotency-Key']; delete headers['If-Match']; expect(await call({ 'Idempotency-Key': 'cancel-api-research', 'If-Match': `"${at}"` })) diff --git a/tests/unit/control-plane/treedx/knowledge/gateway-broker-identity.test.ts b/tests/unit/control-plane/treedx/knowledge/gateway-broker-identity.test.ts index 4f3b934b8..827062a04 100644 --- a/tests/unit/control-plane/treedx/knowledge/gateway-broker-identity.test.ts +++ b/tests/unit/control-plane/treedx/knowledge/gateway-broker-identity.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +const { mint } = vi.hoisted(() => ({ mint: vi.fn((_input: Record) => ({ token: 'test-delegation' })) })); vi.mock('../../../../../src/api/control-plane/treedx/delegation-authority.ts', () => ({ - treeDxDelegationAuthority: () => ({ mint: () => ({ token: 'test-delegation' }) }), + treeDxDelegationAuthority: () => ({ mint }), })); import { resolveKnowledgeGatewayConnection } from '../../../../../src/api/knowledge/gateway-treedx-connection.ts'; @@ -16,6 +17,21 @@ function store(config: Record = {}, treeDx: Record vi.unstubAllEnvs()); describe('knowledge gateway broker identity', () => { + it('grants exact-snapshot indexing without content writes or remote publication only when requested', async () => { + const input = { projectId: 'project', write: false, relationPaths: true, readRefs: ['a'.repeat(40)] }; + await resolveKnowledgeGatewayConnection(store(), input); + const ordinary = mint.mock.calls.at(-1)![0].scope; + expect(ordinary.capabilities).not.toContain('graph:refresh'); + await resolveKnowledgeGatewayConnection(store(), { ...input, refreshGraph: true }); + const indexed = mint.mock.calls.at(-1)![0].scope; + expect(indexed).toEqual({ ...ordinary, capabilities: [...ordinary.capabilities, 'graph:refresh'] }); + for (const denied of ['files:write', 'files:delete', 'workspace:create', 'git:commit', 'git:push', 'policy:write']) + expect(indexed.capabilities).not.toContain(denied); + }); + it.each([[], ['staging'], ['a'.repeat(40), 'b'.repeat(40)]])('rejects refresh grants without one exact snapshot: %j', async (readRefs) => { + await expect(resolveKnowledgeGatewayConnection(store(), { projectId: 'project', write: false, refreshGraph: true, readRefs })) + .rejects.toThrow('one exact commit'); + }); it('uses the runtime service identity instead of the library instance UUID', async () => { vi.stubEnv('TREESEED_TREEDX_NODE_ID', 'runtime-broker'); const connection = await resolveKnowledgeGatewayConnection(store({ TREESEED_TREEDX_NODE_ID: 'configured-broker' }),