diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a014352d1..334c7be07 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -51,7 +51,7 @@ jobs: uses: actions/checkout@v4 with: repository: treeseed-ai/platform - ref: 28df04061cbed62d5ef8f94ad3e40532f9a19c63 + ref: cddfafc226a950e28fc63f07ffbbf61c16a9e3a3 path: .treeseed/tools/platform submodules: false - uses: dtolnay/rust-toolchain@stable @@ -77,7 +77,7 @@ jobs: - name: Build the verified native TreeDX engine working-directory: .treeseed/tools/treedx/apps/api run: MIX_ENV=dev mix compile - - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@8e07d9adaf45ff75e9ea16de691b90d182aa30f8 + - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@83fa5a5d72ae827a70c7846a52b3b2b74fa50678 env: NODE_ENV: production with: diff --git a/README.md b/README.md index 6e36f9632..b32e17366 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,37 @@ Use this package when you operate or develop the Treeseed backend. Ordinary admi Site and external clients reach this package through its REST or MCP surfaces. +### Governance proposal detail contract + +The existing project-authorized `governance.proposals.show` operation is intended +to include an `executionPlan` field alongside its proposal metadata, votes, +events, readiness, and decision data. The plan's canonical source is the exact +TreeDX proposal identified by that proposal row's +`metadata.contentProvenance` (`repositoryId`, `contentPath`, `commitSha`, and +`digest`), resolved and validated through `readExactProposal()` in +`src/api/governance/executable-proposal.ts`. The SQL proposal row is a query +projection, not the authority for plan contents. + +The response must preserve the authored plan fields and values, including work +item IDs, permissions, context references, and estimate references. It must +also preserve whether optional fields were authored: validation must not add +defaults or invent an empty `workItems` array. If the exact, valid canonical +proposal genuinely has no `executionPlan`, the detail response uses +`executionPlan: null`. Failure to find or read that source, a moved or changed +source, a digest mismatch, or invalid proposal content remains an explicit +source error; these cases must not be represented as a missing plan. + +Operators should use the existing proposal detail operation to inspect the +authored plan before coordinating a revision. The returned authored work items +and estimates provide the basis for that review without reconstructing them +from readiness summaries or host-side state. The operation retains its existing +project authorization and unrelated response fields. + +This describes the intended API contract. At source revision +`3497c1dc81328994383bfe9804e06039fac3c673`, the proposal detail method does not +yet return `executionPlan`; implementation and regression coverage are separate +work. + ### MCP mutation requests Tool input and output schemas declare JSON Schema draft-07 and retain the SDK's native validation constraints. diff --git a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml index 1036a2dca..9f788dc3d 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 } @@ -21,6 +24,18 @@ workflow: - id: legacy-graph-unissued action: { verifier: api.golden.legacy-graph-unissued } expect: { status: passed } + - id: retained-contribution-inactive + action: { verifier: api.golden.retained-contribution-inactive } + expect: { status: passed } + - id: retained-contribution-active + action: { verifier: api.golden.retained-contribution-active } + expect: { status: passed } + - id: retained-contribution-unmaterialized + action: { verifier: api.golden.retained-contribution-unmaterialized } + expect: { status: passed } + - id: retained-contribution-no-callback + action: { verifier: api.golden.retained-contribution-no-callback } + expect: { status: passed } - id: subscriber-execution-context-owner action: { verifier: api.golden.subscriber-execution-context-owner } expect: { status: passed } @@ -3132,6 +3147,10 @@ workflow: action: { verifier: api.golden.proposal-contract-authority-native } expect: { status: passed } + - id: proposal-plan-readback-native + action: { verifier: api.golden.proposal-plan-readback-native } + expect: { status: passed } + - id: stopped-reporter-history action: { verifier: api.golden.stopped-reporter-history } expect: { status: passed } diff --git a/guarantees/verifiers/golden.verifiers.yaml b/guarantees/verifiers/golden.verifiers.yaml index a525ab9d0..e42c67a9e 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" @@ -31,6 +36,26 @@ verifiers: ownerPackage: "@treeseed/api" testFile: tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts testName: "preserves legacy authority and condition history while retiring unissued work: unissued" + api.golden.retained-contribution-inactive: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/capacity/execution/graph/contributions/contribution-history.test.ts + testName: "isolates retained contribution drift without weakening issued or unmaterialized authority: inactive" + api.golden.retained-contribution-active: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/capacity/execution/graph/contributions/contribution-history.test.ts + testName: "isolates retained contribution drift without weakening issued or unmaterialized authority: active" + api.golden.retained-contribution-unmaterialized: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/capacity/execution/graph/contributions/contribution-history.test.ts + testName: "isolates retained contribution drift without weakening issued or unmaterialized authority: unmaterialized" + api.golden.retained-contribution-no-callback: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/capacity/execution/graph/contributions/contribution-history.test.ts + testName: "isolates retained contribution drift without weakening issued or unmaterialized authority: no-callback" api.golden.subscriber-execution-context-owner: kind: vitestCase ownerPackage: "@treeseed/api" @@ -70,7 +95,7 @@ verifiers: kind: vitestCase ownerPackage: "@treeseed/api" testFile: tests/unit/control-plane/capacity/execution/graph/contributions/contribution-graph.test.ts - testName: "keeps cross-project subscribers with the same configured name independent without a hidden project filter" + testName: "keeps each cross-project subscriber bound to its own review when configured names coincide" api.golden.contribution-readiness-counter: kind: vitestCase ownerPackage: "@treeseed/api" @@ -80,7 +105,7 @@ verifiers: kind: vitestCase ownerPackage: "@treeseed/api" testFile: tests/unit/control-plane/capacity/execution/graph/contributions/contribution-graph.test.ts - testName: "binds each same-class author to its own acting work and another author own review budget in documentation-first dependency order" + testName: "binds each same-class author to its own acting and reviewing profiles and budgets in documentation-first dependency order" api.golden.contribution-current-profile-custody: kind: vitestCase ownerPackage: "@treeseed/api" @@ -90,7 +115,7 @@ verifiers: kind: vitestCase ownerPackage: "@treeseed/api" testFile: tests/unit/control-plane/capacity/execution/graph/contributions/contribution-graph.test.ts - testName: "refuses missing forged moved foreign or unsubscribed contributor authority and infeasible independent review" + testName: "refuses missing forged moved foreign or unsubscribed contributor authority and missing own review budgets" api.golden.contribution-cohort-lifecycle: kind: vitestCase ownerPackage: "@treeseed/api" @@ -5298,6 +5323,12 @@ verifiers: testFile: tests/unit/control-plane/governance/proposals/architecture/proposal-contract-authority-native.test.ts testName: "creates from the selected governed schema without replacing local products and denies unpublished local-only types" + api.golden.proposal-plan-readback-native: + kind: vitestCase + ownerPackage: "@treeseed/api" + testFile: tests/unit/control-plane/governance/proposals/proposal-show-plan-readback-native.test.ts + testName: "returns the authored plan from the exact TreeDX source and distinguishes absence, source failure, and project denial" + api.golden.stopped-reporter-history: kind: vitestCase ownerPackage: "@treeseed/api" diff --git a/package-lock.json b/package-lock.json index 244ba3587..643aa6b2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@react-email/render": "^2.0.8", "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#8e07d9adaf45ff75e9ea16de691b90d182aa30f8", + "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#83fa5a5d72ae827a70c7846a52b3b2b74fa50678", "@treeseed/treedx": "0.3.0-rc.18", "drizzle-orm": "^0.45.2", "hono": "4.13.3", @@ -1932,8 +1932,8 @@ }, "node_modules/@treeseed/sdk": { "version": "0.13.0-rc.131", - "resolved": "git+ssh://git@github.com/treeseed-ai/sdk.git#8e07d9adaf45ff75e9ea16de691b90d182aa30f8", - "integrity": "sha512-2GUaYTUVktAVCSz7O6oxM4djfomRFO/NugAnWdB+H79svVQkkXX3HXd5GM3nQgHNg/2jQavEeiRltbbeoE+CkA==", + "resolved": "git+ssh://git@github.com/treeseed-ai/sdk.git#83fa5a5d72ae827a70c7846a52b3b2b74fa50678", + "integrity": "sha512-oaXuROj2qVJe9/b6qEaAO8quklC5wtQe4R2FzQUr9T881FI3beAyRcVZgSsMN+0kZo51vLx5XQq6yQ8F0oynIg==", "license": "Apache-2.0", "dependencies": { "@treeseed/treedx": "0.3.0-rc.17", diff --git a/package.json b/package.json index e08e5e165..513695978 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@react-email/render": "^2.0.8", "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#8e07d9adaf45ff75e9ea16de691b90d182aa30f8", + "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#83fa5a5d72ae827a70c7846a52b3b2b74fa50678", "@treeseed/treedx": "0.3.0-rc.18", "drizzle-orm": "^0.45.2", "hono": "4.13.3", diff --git a/src/api/capacity/policy/execution/contribution-bindings.ts b/src/api/capacity/policy/execution/contribution-bindings.ts index 47e31a340..7d174fef6 100644 --- a/src/api/capacity/policy/execution/contribution-bindings.ts +++ b/src/api/capacity/policy/execution/contribution-bindings.ts @@ -45,30 +45,11 @@ export function contributionBindings(source: { teamId: string; frontmatter: Row; }; const acting = new Map(items.map(item => [text(item.id), bind(item, 'acting')])); const targets = items.filter(item => item.review === 'required'); - const budgets = items.filter(item => Object.keys(record(item.reviewEstimate)).length > 0); - const targetByBudget = new Map(); - const eligible = (target: Row, budget: Row) => { - const actor = owners.get(text(target.id))!, reviewer = owners.get(text(budget.id))!; - return (actor.attempt.projectId !== reviewer.attempt.projectId || actor.definition.id !== reviewer.definition.id) - && Boolean(reviewer.definition.activityProfiles.reviewing); - }; - // Each estimated review budget is used once. Matching never invents, scales or - // duplicates a subscriber's promised effort, and never chooses by role/class. - const allocate = (target: Row, visited: Set): boolean => { - for (const budget of budgets) { - const id = text(budget.id); if (visited.has(id) || !eligible(target, budget)) continue; - visited.add(id); - const previous = targetByBudget.get(id); - if (!previous || allocate(targets.find(item => item.id === previous)!, visited)) { - targetByBudget.set(id, text(target.id)); return true; - } - } - return false; - }; - for (const target of targets) if (!allocate(target, new Set())) deny(`Work item ${target.id} lacks a separately estimated independent peer review.`); - const reviewing = new Map([...targetByBudget].map(([budgetId, targetId]) => { - const budget = items.find(item => item.id === budgetId)!; - return [targetId, { profile: bind(budget, 'reviewing'), estimate: record(budget.reviewEstimate), budgetId }] as const; + // Omission selects a separate review execution by the product's own agent. + // Each product retains its own instructions, estimate and authority. + const reviewing = new Map(targets.map(item => { + if (!Object.keys(record(item.reviewEstimate)).length) deny(`Work item ${item.id} lacks its own separately estimated review.`); + return [text(item.id), { profile: bind(item, 'reviewing'), estimate: record(item.reviewEstimate), budgetId: text(item.id) }] as const; })); return { acting, reviewing }; } diff --git a/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts b/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts index ac4d504fe..95b716de4 100644 --- a/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts +++ b/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts @@ -2,7 +2,7 @@ import { validatePortableContentData } from '@treeseed/sdk/content-validation'; import { setTimeout as delay } from 'node:timers/promises'; import { branchMovedConflict } from '../../../../../../discussions/discussion-workspace.ts'; import type { AssignmentResult, ExactEntityReference } from '@treeseed/sdk/agent-capacity'; -import { assignmentAttemptSchema } from '@treeseed/sdk/agent-capacity'; +import { assignmentAttemptSchema, estimateSchema } from '@treeseed/sdk/agent-capacity'; import { CapacityGovernanceError } from '../../../../../database.ts'; import type { DurableProviderAssignment } from '../../../../../repositories/capacity/assignments/assignment.ts'; import { hasCompleteExecutablePlan, proposalApprovalFingerprint, readExactProposal } from '../../../../../../governance/executable-proposal.ts'; @@ -62,15 +62,27 @@ function mergeSubscriberEstimate(input: EstimateMerge): Row { const own = candidateItems.filter(owns), peers = candidateItems.filter(item => !owns(item)); if (!own.length || own.some(item => stable(item.estimateRef) !== stable(ref))) return deny('assignment_estimate_owner_missing', 'Each estimating author must submit its own work with the exact current attempt provenance.'); + if (own.some(item => !estimateSchema.safeParse(item.estimate).success + || (item.review === 'required' && !estimateSchema.safeParse(item.reviewEstimate).success))) + return deny('assignment_estimate_work_item_invalid', 'An estimating result must supply its actual estimate and required review estimate.'); const ordered = (items: Row[]) => [...items].sort((left, right) => text(left.id).localeCompare(text(right.id))); - if (stable(ordered(peers)) !== stable(ordered(frozenItems.filter(item => !owns(item))))) return deny('assignment_estimate_unrelated_change', + const firstIds = new Set(own.map(item => text(item.id))); + const planning = ['draft', 'discussing'].includes(text(input.frozen.status)) && ['draft', 'discussing'].includes(text(input.current.status)); + const firstClaim = (item: Row) => planning && firstIds.has(text(item.id)) && item.estimateRef === undefined + && item.estimate === undefined && item.reviewEstimate === undefined; + for (const planned of frozenItems.filter(firstClaim)) { + const current = currentItems.find(item => item.id === planned.id); + if (!current || (firstClaim(current) && stable(current) !== stable(planned))) return deny('assignment_estimate_source_changed', + 'A newer discussed contribution must not be overwritten or restored by a stale first claim.'); + } + if (stable(ordered(peers)) !== stable(ordered(frozenItems.filter(item => !owns(item) && !firstClaim(item))))) return deny('assignment_estimate_unrelated_change', 'An estimator cannot add, remove or change another subscriber contribution.'); // Submitted peer fields stay frozen above; preserve newer peer contributions // from the current plan when merging only this author's work below. const currentOwn = currentItems.filter(owns); if (stable(ordered(currentOwn)) !== stable(ordered(frozenItems.filter(owns))) && stable(ordered(currentOwn)) !== stable(ordered(own))) return deny( 'assignment_estimate_source_changed', 'A newer contribution from this author must not be overwritten.'); - const merged = { ...input.current, executionPlan: { workItems: ordered([...currentItems.filter(item => !owns(item)), ...own]) } }; + const merged = { ...input.current, executionPlan: { workItems: ordered([...currentItems.filter(item => !owns(item) && !firstClaim(item)), ...own]) } }; if (!validatePortableContentData('proposal', merged).ok) return deny('assignment_estimate_work_item_invalid', 'The combined contributions contain conflicting IDs, dependencies or invalid work.'); // Workflow order is authored content. An unchanged contribution must remain diff --git a/src/api/capacity/services/capacity/execution/executable-proposal-source.ts b/src/api/capacity/services/capacity/execution/executable-proposal-source.ts index cde30b5b9..91f9f2989 100644 --- a/src/api/capacity/services/capacity/execution/executable-proposal-source.ts +++ b/src/api/capacity/services/capacity/execution/executable-proposal-source.ts @@ -88,6 +88,13 @@ export async function loadTeamExecutableProposalSources(store: any, teamId: stri ? await loadProposalGroupContributions(store, { id: row.proposal_id, teamId, projectId: row.project_id }, exact.definition) : null; if (contributions && !contributions.current) { if (!accepted) continue; + // Team growth must not let non-admissible retained history block other + // objectives. Reuse the existing exact-revision freeze; active or never + // materialized authority must still fail rather than disappear. + if (state?.count && state.active === 0 && onFrozenInvalid) { + onFrozenInvalid({ id: text(row.proposal_id), digest: `sha256:${text(row.active_content_hash)}` }); + continue; + } throw new CapacityOperationError(409, 'proposal_contributions_unverified', 'Accepted work requires every subscriber contribution and its actual estimating source to remain verified.', { missing: contributions.missing }); diff --git a/src/api/capacity/services/capacity/workdays/policy/workday-agent-profile-policy.ts b/src/api/capacity/services/capacity/workdays/policy/workday-agent-profile-policy.ts index a964b8bfb..335999513 100644 --- a/src/api/capacity/services/capacity/workdays/policy/workday-agent-profile-policy.ts +++ b/src/api/capacity/services/capacity/workdays/policy/workday-agent-profile-policy.ts @@ -32,6 +32,7 @@ export function compileWorkdayAgentProfileSnapshot(agentClasses: unknown[], sele const available: WorkdayAgentSelectionSnapshot[] = []; for (const value of agentClasses) { const row = record(value); + if (row.status === 'archived') continue; for (const candidate of array(record(row.handlerRefs ?? row.handler_refs ?? row.handler_refs_json).agents)) { const membership = record(candidate).groupIds; if (groupIds !== undefined && !(Array.isArray(membership) && membership.some(id => groupIds.includes(String(id))))) continue; diff --git a/src/api/control-plane/governance/governance-service.ts b/src/api/control-plane/governance/governance-service.ts index ee7bf7138..456903efd 100644 --- a/src/api/control-plane/governance/governance-service.ts +++ b/src/api/control-plane/governance/governance-service.ts @@ -5,6 +5,8 @@ import { reconcileExecutionGraph } from '../repositories/capacity/execution/exec import { resolveKnowledgeGatewayConnection } from '../../knowledge/gateway-treedx-connection.ts'; import { exactEntityReferenceSchema } from '@treeseed/sdk/agent-capacity'; import { assertGovernanceProposalReady } from '../../store/governance/policy/contracts/governance-proposal-readiness.ts'; +import { readExactProposal } from '../../governance/executable-proposal.ts'; +import { parseFrontmatterDocument } from '../../content/frontmatter.ts'; type Principal = { id: string; roles?: string[]; permissions?: string[]; metadata?: Record } | undefined; @@ -296,7 +298,10 @@ export function createGovernanceService(store: any, discussions: { create: (prin async proposal(principal: Principal, projectId: string, proposalId: string) { await projectFor(store, principal, projectId); const proposal = await proposalFor(store, projectId, proposalId); - return { ...proposal, votes: await store.listGovernanceProposalVotes(proposal.id), + const exact = await readExactProposal(store, proposal); + const authored = parseFrontmatterDocument(exact.source).frontmatter; + return { ...proposal, executionPlan: Object.hasOwn(authored, 'executionPlan') ? authored.executionPlan : null, + votes: await store.listGovernanceProposalVotes(proposal.id), events: await store.listGovernanceEvents({ proposalId: proposal.id, limit: 100 }), readiness: await store.governanceProposalReadiness(proposal.id), decision: proposal.decisionId ? await store.getGovernanceDecision(proposal.decisionId) : null }; diff --git a/src/api/control-plane/inbox/inbox-service.ts b/src/api/control-plane/inbox/inbox-service.ts index 3e7cf6bf6..1a4933776 100644 --- a/src/api/control-plane/inbox/inbox-service.ts +++ b/src/api/control-plane/inbox/inbox-service.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); diff --git a/src/api/control-plane/inbox/question-content.ts b/src/api/control-plane/inbox/question-content.ts index a6ac15a8f..a6db42e9f 100644 --- a/src/api/control-plane/inbox/question-content.ts +++ b/src/api/control-plane/inbox/question-content.ts @@ -8,23 +8,25 @@ const slug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/gu, '-') 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 workspace = await connection.client.createWorkspace({ repoId: connection.repositoryId, baseRef: connection.baseRef, branchName, mode: 'writable', allowedPaths: [path] }); 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); diff --git a/tests/unit/control-plane/capacity/execution/architecture/contributions/contribution-profile-custody-native.test.ts b/tests/unit/control-plane/capacity/execution/architecture/contributions/contribution-profile-custody-native.test.ts index 801020d2a..ade375307 100644 --- a/tests/unit/control-plane/capacity/execution/architecture/contributions/contribution-profile-custody-native.test.ts +++ b/tests/unit/control-plane/capacity/execution/architecture/contributions/contribution-profile-custody-native.test.ts @@ -53,7 +53,7 @@ it('resolves current contribution fingerprints through native profile custody wh const bound = kind === 'acting' ? bindings.acting.get(item.id)! : bindings.reviewing.get(item.id)!.profile; const owner = attempts.find(attempt => attempt.effectiveProfile.profileRef.id === bound.definition.id)!; expect(bound.estimateAuthority.id).toBe(owner.id); - if (kind === 'reviewing') expect(bound.definition.id).not.toBe(definitions[index]!.id); + if (kind === 'reviewing') expect(bound.definition.id).toBe(definitions[index]!.id); const node = { id: `${kind}-${item.id}`, projectId: 'project', kind, agentClass: 'maintenance', authorityRefs: [bound.agentReference] } as ExecutionNode; const resolved = await effectiveProfile(f.store, node); expect(resolved.profile.profileRef).toEqual({ store: 'treedx', model: 'agent', id: bound.definition.id, diff --git a/tests/unit/control-plane/capacity/execution/graph/contributions/contribution-graph.test.ts b/tests/unit/control-plane/capacity/execution/graph/contributions/contribution-graph.test.ts index 12dcb0e43..09791af97 100644 --- a/tests/unit/control-plane/capacity/execution/graph/contributions/contribution-graph.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/contributions/contribution-graph.test.ts @@ -40,7 +40,22 @@ function input() { return { teamId: 'team', revision: 1, sources: [source], profiles: Object.fromEntries(definitions.map(definition => [`project:${definition.id}`, definition])) }; } -it('binds each same-class author to its own acting work and another author own review budget in documentation-first dependency order', () => { +it('gives a sole subscribed author a distinct review execution using its own profile, budget and provenance', () => { + const supplied = input(); supplied.sources[0]!.frontmatter.executionPlan.workItems.splice(1); + supplied.sources[0]!.estimatingAttempts.splice(1); + const first = Object.entries(supplied.profiles)[0]!; supplied.profiles = { [first[0]]: first[1] }; + const held = structuredClone(supplied), graph = projectTeamExecutionGraph(supplied); + const actor = graph.nodes.find(node => node.kind === 'acting')!, review = graph.nodes.find(node => node.kind === 'reviewing')!; + const item = supplied.sources[0]!.frontmatter.executionPlan.workItems[0]!; + expect(review.id).not.toBe(actor.id); + expect(review.authorityRefs).toContainEqual(actor.authorityRefs!.find(ref => ref.model === 'agent')); + expect(review.authorityRefs).toContainEqual(item.estimateRef); + expect(review.estimate).toEqual(item.reviewEstimate); + expect(graph.edges).toContainEqual(expect.objectContaining({ fromNodeId: actor.id, toNodeId: review.id, provenance: 'review-pair' })); + expect(supplied).toEqual(held); +}); + +it('binds each same-class author to its own acting and reviewing profiles and budgets in documentation-first dependency order', () => { const supplied = input(), before = structuredClone(supplied), graph = projectTeamExecutionGraph(supplied); const items = supplied.sources[0]!.frontmatter.executionPlan.workItems; for (const item of items) { @@ -51,7 +66,7 @@ it('binds each same-class author to its own acting work and another author own r const actorId = actorReference.id; const reviewerId = reviewerReference.id; expect(actorId).toBe(`project/${item.id.replace(/-work$/u, '')}`); - expect(reviewerId).not.toBe(actorId); expect(actor.agentClass).toBe('maintenance'); expect(review.agentClass).toBe('maintenance'); + expect(reviewerId).toBe(actorId); expect(actor.agentClass).toBe('maintenance'); expect(review.agentClass).toBe('maintenance'); const actorDefinition = Object.values(supplied.profiles).find(definition => definition.id === actorId)!; const reviewerDefinition = Object.values(supplied.profiles).find(definition => definition.id === reviewerId)!; expect(actorReference).toEqual(agentProfileReference(actorDefinition)); @@ -69,21 +84,21 @@ it('binds each same-class author to its own acting work and another author own r expect(supplied).toEqual(before); }); -it('refuses missing forged moved foreign or unsubscribed contributor authority and infeasible independent review', () => { - for (const fault of ['missing', 'forged', 'ordinal', 'foreign', 'unsubscribed', 'self-only'] as const) { +it('refuses missing forged moved foreign or unsubscribed contributor authority and missing own review budgets', () => { + for (const fault of ['missing', 'forged', 'ordinal', 'foreign', 'unsubscribed', 'missing-review'] as const) { const supplied = input(), source = supplied.sources[0]!; if (fault === 'missing') source.estimatingAttempts = []; if (fault === 'forged') source.frontmatter.executionPlan.workItems[0]!.estimateRef.id = 'unverified-attempt'; if (fault === 'ordinal') source.frontmatter.executionPlan.workItems[0]!.estimateRef.revision++; if (fault === 'foreign') source.estimatingAttempts[0]!.teamId = 'foreign'; if (fault === 'unsubscribed') Object.values(supplied.profiles)[0]!.groupIds = ['another-team']; - if (fault === 'self-only') { source.frontmatter.executionPlan.workItems.splice(1); source.estimatingAttempts.splice(1); } + if (fault === 'missing-review') Reflect.deleteProperty(source.frontmatter.executionPlan.workItems[0]!, 'reviewEstimate'); const before = structuredClone(supplied); expect(() => projectTeamExecutionGraph(supplied)).toThrow(); expect(supplied).toEqual(before); } }); -it('keeps cross-project subscribers with the same configured name independent without a hidden project filter', () => { +it('keeps each cross-project subscriber bound to its own review when configured names coincide', () => { const supplied = input(), source = supplied.sources[0]!, projects = ['ui', 'admin']; source.frontmatter.executionPlan.workItems.splice(2); source.estimatingAttempts.splice(2); const definitions = Object.values(supplied.profiles).slice(0, 2); @@ -97,7 +112,7 @@ it('keeps cross-project subscribers with the same configured name independent wi for (const actor of graph.nodes.filter(node => node.kind === 'acting')) { const reviewId = graph.edges.find(edge => edge.fromNodeId === actor.id && edge.provenance === 'review-pair')!.toNodeId; const reviewer = graph.nodes.find(node => node.id === reviewId)!; - expect(reviewer.projectId).not.toBe(actor.projectId); + expect(reviewer.projectId).toBe(actor.projectId); expect(reviewer.authorityRefs!.find(ref => ref.model === 'agent')!.id).toBe('shared-worker'); expect(actor.authorityRefs!.find(ref => ref.model === 'agent')!.id).toBe('shared-worker'); } diff --git a/tests/unit/control-plane/capacity/execution/graph/contributions/contribution-history.test.ts b/tests/unit/control-plane/capacity/execution/graph/contributions/contribution-history.test.ts new file mode 100644 index 000000000..ef14e52b8 --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/graph/contributions/contribution-history.test.ts @@ -0,0 +1,51 @@ +import { expect, it, vi } from 'vitest'; + +const exactProposal = vi.hoisted(() => vi.fn()); +vi.mock('../../../../../../../src/api/capacity/services/capacity/execution/proposal-contributions.ts', () => ({ + loadProposalGroupContributions: async (_store: unknown, proposal: { id: string }) => ({ + current: proposal.id !== 'retained', missing: proposal.id === 'retained' ? ['project/project/new-subscriber'] : [], + attempts: [], profiles: {}, independent: true, + }), +})); +vi.mock('../../../../../../../src/api/governance/executable-proposal.ts', async importOriginal => ({ + ...await importOriginal(), + readExactProposal: exactProposal, +})); +import { loadTeamExecutableProposalSources } from '../../../../../../../src/api/capacity/services/capacity/execution/executable-proposal-source.ts'; +import { readyContributionProposal } from '../../../../governance/proposals/architecture/ready-proposal-fixture.ts'; + +it.each(['inactive', 'active', 'unmaterialized', 'no-callback'])('isolates retained contribution drift without weakening issued or unmaterialized authority: %s', async mode => { + const ref = { id: 'retained', repository: 'library', commit: 'b'.repeat(40), + path: 'proposals/retained.mdx', digest: `sha256:${'a'.repeat(64)}` }; + const retained = { proposal_id: 'retained', project_id: 'project', active_version: 1, + active_content_hash: 'a'.repeat(64), accepted_decision_id: 'decision', proposal_version: 1, + decision_record_json: { proposalRef: ref } }; + const healthy = { proposal_id: 'healthy', project_id: 'other-project', active_version: 1, + active_content_hash: 'c'.repeat(64), accepted_decision_id: null }; + const graph = mode === 'unmaterialized' ? [] : [{ id: 'retained-node', + source_ref_json: { model: 'proposal', id: 'retained', digest: ref.digest }, status: 'blocked' }]; + const held = structuredClone({ retained, healthy, graph }); + const store = { all: vi.fn(async (sql: string) => { + if (sql.includes('FROM governance_proposals')) return [retained, healthy]; + if (sql.includes('FROM execution_nodes')) return graph; + if (sql.includes('FROM capacity_provider_assignments')) return mode === 'active' ? [{ execution_node_id: 'retained-node' }] : []; + return []; + }) }; + exactProposal.mockImplementation(async (_store, row) => ({ + ref: row.proposal_id === 'retained' ? ref : { ...ref, id: 'healthy', digest: `sha256:${'c'.repeat(64)}` }, + definition: { ...readyContributionProposal(), id: row.proposal_id, + status: row.proposal_id === 'retained' ? 'decided' : 'ready' }, + })); + const frozen: unknown[] = []; + const outcome = loadTeamExecutableProposalSources(store, 'team', undefined, + mode === 'no-callback' ? undefined : source => frozen.push(source)); + if (mode === 'inactive') { + await expect(outcome).resolves.toMatchObject([{ projectId: 'other-project', frontmatter: { id: 'healthy' } }]); + expect(frozen).toEqual([{ id: ref.id, digest: ref.digest }]); + } else { + await expect(outcome).rejects.toMatchObject({ code: 'proposal_contributions_unverified', + details: { missing: ['project/project/new-subscriber'] } }); + expect(frozen).toEqual([]); + } + expect({ retained, healthy, graph }).toEqual(held); +}); diff --git a/tests/unit/control-plane/capacity/planning/contributions/estimate-ownership.test.ts b/tests/unit/control-plane/capacity/planning/contributions/estimate-ownership.test.ts index 914d90bcf..b818cc670 100644 --- a/tests/unit/control-plane/capacity/planning/contributions/estimate-ownership.test.ts +++ b/tests/unit/control-plane/capacity/planning/contributions/estimate-ownership.test.ts @@ -26,6 +26,39 @@ function input(name: string, itemId: string, frozen: Row = draft) { } describe('subscriber-owned estimate contributions', () => { + it('lets each actual author claim an unestimated discussed item while retaining concurrent peer authority', () => { + const writer = input('technical-writer', 'document-boundary'), tester = input('tester', 'reproduce-request'); + const unestimated = (work: Row) => Object.fromEntries(Object.entries(work) + .filter(([key]) => !['estimate', 'estimateRef', 'reviewEstimate'].includes(key))); + const writerPlan = unestimated(writer.work), testerPlan = unestimated(tester.work); + const frozen = { ...draft, executionPlan: { workItems: [writerPlan, testerPlan] } }; + const writerCandidate = { ...frozen, executionPlan: { workItems: [writer.work, testerPlan] } }; + const testerCandidate = { ...frozen, executionPlan: { workItems: [writerPlan, tester.work] } }; + const held = structuredClone({ frozen, writerCandidate, testerCandidate }); + const first = mergeAssignmentEstimate({ ...writer, frozen, current: frozen, candidate: writerCandidate } as never); + const both = mergeAssignmentEstimate({ ...tester, frozen, current: first, candidate: testerCandidate } as never); + expect((both.executionPlan as { workItems: Row[] }).workItems).toEqual([writer.work, tester.work]); + expect(mergeAssignmentEstimate({ ...writer, frozen, current: both, candidate: writerCandidate } as never)).toEqual(both); + for (const workItems of [[testerPlan], [{ ...writerPlan, objective: 'Newer discussed scope.' }, testerPlan]]) + expect(() => mergeAssignmentEstimate({ ...writer, frozen, current: { ...frozen, executionPlan: { workItems } }, + candidate: writerCandidate } as never)).toThrow(/newer discussed contribution/u); + const foreign = input('another-subscriber', 'document-boundary'); + expect(() => mergeAssignmentEstimate({ ...foreign, frozen, current: both, + candidate: { ...frozen, executionPlan: { workItems: [foreign.work, testerPlan] } } } as never)).toThrow(/conflicting IDs/u); + for (const peers of [[], [{ ...testerPlan, objective: 'Altered peer scope.' }]]) expect(() => mergeAssignmentEstimate({ + ...writer, frozen, current: frozen, candidate: { ...frozen, executionPlan: { workItems: [writer.work, ...peers] } }, + } as never)).toThrow(/another subscriber contribution/u); + expect({ frozen, writerCandidate, testerCandidate }).toEqual(held); + }); + it('refuses an estimating result that supplies authority without its actual estimate and required review estimate', () => { + const author = input('engineer', 'implementation'); + for (const field of ['estimate', 'reviewEstimate']) { + const incomplete = { ...author.work } as Row; Reflect.deleteProperty(incomplete, field); + const supplied = { ...author, candidate: { ...draft, executionPlan: { workItems: [incomplete] } } }, held = structuredClone(supplied); + expect(() => mergeAssignmentEstimate(supplied as never)).toThrow(/actual estimate/u); + expect(supplied).toEqual(held); + } + }); it('keeps an already integrated contribution current when the proposal orders work by workflow rather than alphabetically', () => { const engineer = input('engineer', 'z-repair-request'), tester = input('tester', 'a-reproduce-request'); const current = { ...draft, executionPlan: { workItems: [engineer.work, tester.work] } }, held = structuredClone(current); diff --git a/tests/unit/control-plane/capacity/planning/contributions/estimate-readback.test.ts b/tests/unit/control-plane/capacity/planning/contributions/estimate-readback.test.ts index 4b5b1b54c..aab50fba5 100644 --- a/tests/unit/control-plane/capacity/planning/contributions/estimate-readback.test.ts +++ b/tests/unit/control-plane/capacity/planning/contributions/estimate-readback.test.ts @@ -8,6 +8,7 @@ import { splitPostgresSqlStatements } from '../../../../../../src/api/persistenc import { loadProposalGroupContributions } from '../../../../../../src/api/capacity/services/capacity/execution/proposal-contributions.ts'; import { projectTeamExecutionGraph } from '../../../../../../src/api/capacity/policy/execution/execution-graph-projector.ts'; import { effectiveProfile } from '../../../../../../src/api/capacity/services/build/execution-node-profile.ts'; +import { hasCompleteExecutablePlan, readExactProposal } from '../../../../../../src/api/governance/executable-proposal.ts'; import { loadTeamExecutableProposalSources } from '../../../../../../src/api/capacity/services/capacity/execution/executable-proposal-source.ts'; import { verifyAssignmentContent, recordAssignmentContentIntegration } from '../../../../../../src/api/capacity/services/capacity/assignments/lifecycle/assignment-content-readback.ts'; @@ -29,10 +30,19 @@ describe('own contribution through owning SQL public HTTP client and native Git' activityProfiles: { estimating: { ...profile, permissions: { content: { read: ['proposal'], write: ['proposal'] }, tools: ['source.read'] } }, acting: profile, reviewing: { ...profile, permissions: { content: { read: ['proposal', 'decision'], write: ['decision'] }, tools: ['source.read', 'verification'] } } } })); for (const definition of definitions) f.publishContent(`agents/${definition.name}.mdx`, definition); + const discussed = definitions.map(definition => ({ id: `${definition.name}-work`, activity: 'acting', + objective: `Own ${definition.name} provider runtime repair.`, workspace: 'git', review: 'required', maximumReviewCycles: 2, + dependsOn: [], acceptanceCriteria: ['Deliver the accepted source boundary repair.'], + requestedPermissions: { content: { read: ['proposal'], write: [] }, tools: ['source.read', 'source.write', 'verification'] }, + contextRefs: [{ store: 'git', model: 'repository', id: 'agent', repository: 'treeseed-ai/agent', commit: 'a'.repeat(40) }] })); const draft = { schemaVersion: 'treeseed.proposal/v2', id: 'proposal', projectId: 'project', groupIds: ['provider-runtime'], title: 'Preserve prepared workload inputs', request: 'Document and repair the actual model request handoff.', - summary: 'Own documentation and engineering contributions to the opaque runner repair.', status: 'discussing' }; + summary: 'Own documentation and engineering contributions to the opaque runner repair.', status: 'discussing', + executionPlan: { workItems: discussed } }; const frozen = await f.publish(draft), path = 'proposals/proposal.mdx'; + const initialRow = (await f.query('SELECT * FROM governance_proposals')).rows[0]!; + expect((await readExactProposal(f.store, initialRow)).definition.executionPlan).toEqual(draft.executionPlan); + expect(hasCompleteExecutablePlan(draft)).toBe(false); const sourceRef = { store: 'treedx', model: 'proposal', id: 'proposal', revision: 1, repository: 'repository', commit: frozen.commit, digest: `sha256:${frozen.digest}`, path }; const inputs = ['technical-writer', 'engineer'].map(name => { @@ -44,12 +54,11 @@ describe('own contribution through owning SQL public HTTP client and native Git' workspace: { mode: 'treedx', workspaceId: `workspace-${name}`, repository: 'repository', baseCommit: frozen.commit, writablePaths: [path] }, grant: { contentRead: [sourceRef], contentWrite: [sourceRef], sourceRead: [], sourceWrite: [], tools: [] } }); const estimate = { expectedSeconds: 120, maximumSeconds: 240, rationale: 'Controlled bounded contribution input, not measured agent work.' }; - const item = { id: `${name}-work`, activity: 'acting', objective: `Own ${name} provider runtime repair.`, workspace: 'git', - review: 'required', maximumReviewCycles: 2, dependsOn: [], estimateRef: { store: 'postgresql', model: 'assignment-attempt', id, revision: attempt.attempt }, - estimate, reviewEstimate: estimate, acceptanceCriteria: ['Deliver the accepted source boundary repair.'], - requestedPermissions: { content: { read: ['proposal'], write: [] }, tools: ['source.read', 'source.write', 'verification'] }, - contextRefs: [{ store: 'git', model: 'repository', id: 'agent', repository: 'treeseed-ai/agent', commit: 'a'.repeat(40) }] }; - const candidate = { ...draft, executionPlan: { workItems: [item] } }, committed = f.publishContent(path, candidate); + const planned = discussed.find(item => item.id === `${name}-work`)!; + const item = { ...planned, estimateRef: { store: 'postgresql', model: 'assignment-attempt', id, revision: attempt.attempt }, + estimate, reviewEstimate: estimate }; + const candidate = { ...draft, executionPlan: { workItems: [item, ...discussed.filter(peer => peer.id !== item.id)] } }, + committed = f.publishContent(path, candidate); const result = assignmentResultSchema.parse({ schemaVersion: 'treeseed.assignment-result/v1', id: `result-${name}`, assignmentId: id, status: 'completed', summary: 'Controlled native component input; no live agent completion claimed.', references: [{ kind: 'treedx', projectId: 'project', repository: 'repository', commit: committed.commit, path, workspaceId: attempt.workspace.mode === 'treedx' ? attempt.workspace.workspaceId : '' }], @@ -62,8 +71,11 @@ describe('own contribution through owning SQL public HTTP client and native Git' [assignment.id, 'controlled-membership', 'team', 'project', 'controlled-provider', 'maintenance', 'planning', 'completed', assignment.agentId, JSON.stringify(assignment.assignmentAttempt), JSON.stringify(result), original.createdAt, original.createdAt]); let current: Record = draft; - for (const input of inputs) current = mergeAssignmentEstimate({ frozen: draft, current, candidate: input.candidate, - assignment: input.assignment, ownedEstimateRefs: [] }); + for (const [index, input] of inputs.entries()) { + current = mergeAssignmentEstimate({ frozen: draft, current, candidate: input.candidate, + assignment: input.assignment, ownedEstimateRefs: [] }); + expect(hasCompleteExecutablePlan(current)).toBe(index === inputs.length - 1); + } await f.publish(current); const proposal = (await f.query('SELECT * FROM governance_proposals')).rows[0]!; await f.query(`INSERT INTO project_agent_classes (id,team_id,project_id,slug,name,handler_refs_json,metadata_json,created_at,updated_at) @@ -149,6 +161,60 @@ describe('own contribution through owning SQL public HTTP client and native Git' assignment: refreshed, ownedEstimateRefs: [writer.candidate.executionPlan.workItems[0]!.estimateRef] } as never)) .toEqual(changedPeer); expect(await f.snapshot()).toEqual(held); + // Real portfolio team growth must not let retained accepted history + // prevent a new draft. No historical contribution is invented for it. + const accepted = await readExactProposal(store, proposal); + await f.query("UPDATE governance_proposals SET status='accepted',decision_id='retained-decision' WHERE id='proposal'"); + await f.query(`INSERT INTO governance_decisions + (id,team_id,project_id,proposal_id,proposal_version,proposal_content_hash,status,title,summary, + governance_provider_id,decision_record_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ['retained-decision', 'team', 'project', 'proposal', 1, proposal.active_content_hash, 'accepted', + 'Retained accepted repair', 'Controlled historical authority, not a live approval.', 'default', + JSON.stringify({ proposalRef: accepted.ref }), original.createdAt, original.createdAt]); + await f.query(`INSERT INTO execution_nodes + (id,team_id,project_id,kind,pair_role,source_ref_json,rule_revision,node_revision,status, + graph_revision_created,graph_revision_updated,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ['retained-node', 'team', 'project', 'acting', 'actor', JSON.stringify(accepted.ref), 1, 1, + 'blocked', 1, 1, original.createdAt, original.createdAt]); + const newcomer = { ...definitions[1]!, id: 'project/integrator', name: 'integrator' }; + const grown = f.publishContent('agents/integrator.mdx', newcomer); + await f.query('UPDATE project_agent_classes SET handler_refs_json=?,metadata_json=? WHERE id=?', [ + JSON.stringify({ agents: [...definitions, newcomer] }), JSON.stringify({ source: 'project-library', + immutableRef: grown.commit, definitionPaths: [...definitions.map(item => `agents/${item.name}.mdx`), 'agents/integrator.mdx'] }), 'maintenance']); + expect(await loadProposalGroupContributions(store as never, subject, current)).toMatchObject({ + current: false, missing: ['project/project/integrator'] }); + const nextDraft = { ...draft, id: 'next-objective' }, nextSource = f.publishContent('proposals/next-objective.mdx', nextDraft); + await f.query(`INSERT INTO governance_proposals + (id,team_id,project_id,title,summary,body,active_content_hash,governance_provider_id,metadata_json,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, ['next-objective', 'team', 'project', nextDraft.title, nextDraft.summary, + nextDraft.request, nextSource.digest, 'default', JSON.stringify({ contentProvenance: { repositoryId: 'repository', + contentPath: 'proposals/next-objective.mdx', commitSha: nextSource.commit, digest: nextSource.digest } }), original.createdAt, original.createdAt]); + const retained = await f.snapshot(), frozenHistory: Array<{ id: string; digest: string }> = []; + await expect(loadTeamExecutableProposalSources(store, 'team', undefined, source => frozenHistory.push(source))).resolves.toEqual([]); + expect(frozenHistory).toEqual([{ id: 'proposal', digest: accepted.ref.digest }]); + expect(await f.snapshot()).toEqual(retained); + await expect(loadTeamExecutableProposalSources(store, 'team')).rejects.toMatchObject({ code: 'proposal_contributions_unverified' }); + await f.query('BEGIN'); + try { + await f.query("DELETE FROM execution_nodes WHERE id='retained-node'"); + await expect(loadTeamExecutableProposalSources(store, 'team', undefined, () => undefined)) + .rejects.toMatchObject({ code: 'proposal_contributions_unverified' }); + } finally { await f.query('ROLLBACK'); } + expect(await f.snapshot()).toEqual(retained); + for (const fault of ['moved', 'denied'] as const) { + f.setFault(fault); await expect(loadTeamExecutableProposalSources(store, 'team', undefined, () => undefined)).rejects.toThrow(); + expect(await f.snapshot()).toEqual(retained); + } + f.setFault('none'); + await f.query(`INSERT INTO capacity_provider_assignments + (id,membership_id,team_id,project_id,capacity_provider_id,project_agent_class_id,mode,status, + execution_node_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + ['active-retained', 'membership', 'team', 'project', 'provider', 'maintenance', 'acting', 'running', + 'retained-node', original.createdAt, original.createdAt]); + const active = await f.snapshot(); + await expect(loadTeamExecutableProposalSources(store, 'team', undefined, () => undefined)) + .rejects.toMatchObject({ code: 'proposal_contributions_unverified', details: { missing: ['project/project/integrator'] } }); + expect(await f.snapshot()).toEqual(active); // Actual owning SQL, SDK HTTP transport and committed Git are exercised. // The existing isolated endpoint is not a native TreeDX server or managed // execution/approval/permission proof. Those layers remain required. diff --git a/tests/unit/control-plane/capacity/planning/selection.test.ts b/tests/unit/control-plane/capacity/planning/selection.test.ts index 6bebd3560..d1a981f75 100644 --- a/tests/unit/control-plane/capacity/planning/selection.test.ts +++ b/tests/unit/control-plane/capacity/planning/selection.test.ts @@ -15,6 +15,20 @@ const classes = [ ]; describe('exact activity selection before living-node admission', () => { + it('omits archived legacy definitions before validation while preserving active and paused group subscribers', () => { + const current = (name: string, status: string) => ({ id: name, slug: 'maintenance', status, handlerRefs: { agents: [ + { ...definition(name, 'maintenance', { planning: profile('writer') }), groupIds: ['project-api'] }, + ] } }); + const archived = { id: 'legacy', status: 'archived', handlerRefs: { agents: [{ agentId: 'legacy', groupIds: ['project-api'] }] } }; + const rows = [archived, current('current', 'active'), current('paused', 'paused')], held = structuredClone(rows); + expect(compileWorkdayAgentProfileSnapshot(rows, { activityTypes: ['planning'] }, ['project-api']).agents.map(x => x.definition.id)) + .toEqual(['sdk/current', 'sdk/paused']); + for (const status of ['active', 'paused']) expect(() => compileWorkdayAgentProfileSnapshot([ + { ...archived, status }, rows[1]!, rows[2]!, + ], undefined, ['project-api'])).toThrow(/invalid minimal agent/u); + expect(() => compileWorkdayAgentProfileSnapshot([archived], undefined, ['project-api'])).toThrow(/no eligible/u); + expect(rows).toEqual(held); + }); it('admits every v2 group subscriber to estimating before a contribution plan exists regardless of class or project', () => { const snapshot = (project: string, name: string, groupIds = ['provider-runtime']) => ({ agents: [{ definition: { ...definition(name, 'maintenance', { planning: profile('writer'), estimating: profile('writer') }), id: `${project}/${name}`, groupIds }, diff --git a/tests/unit/control-plane/governance-operations.test.ts b/tests/unit/control-plane/governance-operations.test.ts index 44f01bd75..533646ccb 100644 --- a/tests/unit/control-plane/governance-operations.test.ts +++ b/tests/unit/control-plane/governance-operations.test.ts @@ -12,7 +12,7 @@ describe('governance catalog read operations', () => { openProposal: vi.fn(async () => ({ id: 'proposal-1', status: 'open' })), startVoting: vi.fn(async () => ({ id: 'proposal-1', status: 'voting' })), vote: vi.fn(async () => ({ id: 'proposal-1' })), evaluate: vi.fn(async () => ({ id: 'proposal-1' })), withdraw: vi.fn(async () => ({ id: 'proposal-1', status: 'withdrawn' })), supersede: vi.fn(async () => ({ id: 'proposal-1', status: 'superseded' })), - proposals: vi.fn(async () => ({ items: [] })), proposal: vi.fn(async () => ({ id: 'proposal-1' })), + proposals: vi.fn(async () => ({ items: [] })), proposal: vi.fn(async () => ({ id: 'proposal-1', executionPlan: { workItems: [{ id: 'authored' }] } })), proposalEvents: vi.fn(async () => ({ items: [] })), decisions: vi.fn(async () => ({ items: [] })), decision: vi.fn(async () => ({ id: 'decision-1' })), decisionEvents: vi.fn(async () => ({ items: [] })), }; @@ -30,7 +30,8 @@ describe('governance catalog read operations', () => { CONTROL_PLANE_OPERATIONS.governance.decision, CONTROL_PLANE_OPERATIONS.governance.decisionEvents, ]); const context = { interface: 'rest' as const, requestId: 'request-1', principal: { id: 'user-1' } }; - await operations[13].handler({ path: { projectId: 'project-1', proposalId: 'proposal-1' }, query: {}, body: undefined }, context); + await expect(operations[13].handler({ path: { projectId: 'project-1', proposalId: 'proposal-1' }, query: {}, body: undefined }, context)) + .resolves.toEqual({ id: 'proposal-1', executionPlan: { workItems: [{ id: 'authored' }] } }); expect(governance.proposal).toHaveBeenCalledWith(context.principal, 'project-1', 'proposal-1'); await operations[4].handler({ path: { projectId: 'project-1', proposalId: 'proposal-1' }, query: {}, body: { title: 'Updated' } }, { ...context, ifMatch: '3' }); diff --git a/tests/unit/control-plane/governance/proposals/architecture/native-subscriber-inputs.ts b/tests/unit/control-plane/governance/proposals/architecture/native-subscriber-inputs.ts index 55fb9b772..43094cfd6 100644 --- a/tests/unit/control-plane/governance/proposals/architecture/native-subscriber-inputs.ts +++ b/tests/unit/control-plane/governance/proposals/architecture/native-subscriber-inputs.ts @@ -92,7 +92,11 @@ export function expectNativeSubscriberGraph(input: ReturnType node.workItemId === workItemId && node.pairRole === 'reviewer'); assert.ok(reviewer); expect(actor.authorityRefs).toContainEqual(expect.objectContaining({ model: 'agent', id: subscriber.definition.id })); expect(actor.authorityRefs).toContainEqual(input.declaration.executionPlan.workItems[index]!.estimateRef); - expect(reviewer.authorityRefs).toContainEqual(expect.objectContaining({ model: 'agent', id: input.subscribers[1 - index]!.definition.id })); + expect(reviewer.authorityRefs).toContainEqual(expect.objectContaining({ model: 'agent', id: subscriber.definition.id })); + expect(reviewer.authorityRefs).toContainEqual(input.declaration.executionPlan.workItems[index]!.estimateRef); + expect(reviewer.id).not.toBe(actor.id); + expect(reviewer.kind).toBe('reviewing'); + expect(reviewer.estimate).toEqual(input.declaration.executionPlan.workItems[index]!.reviewEstimate); } } diff --git a/tests/unit/control-plane/governance/proposals/proposal-show-plan-readback-native.test.ts b/tests/unit/control-plane/governance/proposals/proposal-show-plan-readback-native.test.ts new file mode 100644 index 000000000..ccd9e756c --- /dev/null +++ b/tests/unit/control-plane/governance/proposals/proposal-show-plan-readback-native.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { createGovernanceService } from '../../../../../src/api/control-plane/governance/governance-service.ts'; +import { createGovernanceOperations } from '../../../../../src/api/control-plane/catalog/governance-operations.ts'; +import { createOperationHttpHandler } from '../../../../../src/api/control-plane/http/operation-http-handler.ts'; +import { readExactProposal } from '../../../../../src/api/governance/executable-proposal.ts'; +import { getGovernanceProposalMethod } from '../../../../../src/api/store/governance/policy/queries/proposals/get-governance-proposal.ts'; +import { proposalNativeFixture } from './architecture/proposal-native-fixture.ts'; + +describe('native shared proposal detail read', () => { + it('returns the authored plan from the exact TreeDX source and distinguishes absence, source failure, and project denial', async () => { + const native = await proposalNativeFixture(); + try { + const item: Record = { + id: 'verify-boundary', activity: 'acting', workspace: 'read-only', review: 'required', + objective: 'Independently verify the exact governed boundary.', dependsOn: [], + requestedPermissions: { content: { read: ['proposal'], write: [] }, tools: [] }, + acceptanceCriteria: ['Read back the exact source and record independent verification.'], + estimate: { expectedSeconds: 30, maximumSeconds: 60, rationale: 'Controlled own implementation estimate.' }, + reviewEstimate: { expectedSeconds: 10, maximumSeconds: 20, rationale: 'Controlled own independent review estimate.' }, + maximumReviewCycles: 2, + }; + const definition: Record = { + schemaVersion: 'treeseed.proposal/v2', id: 'proposal', projectId: 'project', title: 'Governed boundary', + request: 'Verify the exact governed boundary.', summary: 'One bounded, independently reviewed work item.', + status: 'draft', groupIds: ['portfolio-runtime'], executionPlan: { workItems: [item] }, + }; + item.estimateRef = { store: 'postgresql', model: 'assignment-attempt', id: 'authored-estimate', revision: 7 }; + const authoredPlan = structuredClone(definition.executionPlan); + const proposalStore = { ...native.store, ensureInitialized: async () => undefined }; + let exact = await native.publish(definition); + const row = await getGovernanceProposalMethod.call(proposalStore as never, 'proposal'); + expect(row).toMatchObject({ id: 'proposal', projectId: 'project', activeContentHash: exact.digest }); + const exactRead = await readExactProposal(native.store, row!); + expect(exactRead.source).toBe(exact.source); + expect(exactRead.ref).toMatchObject({ revision: 1, commit: exact.commit, digest: `sha256:${exact.digest}` }); + const store = { + ...native.store, + getProjectDetails: async () => ({ project: { id: 'project', teamId: 'team' } }), + principalCanAccessTeam: async (principal: { id?: string }, teamId: string) => principal.id === 'reader' && teamId === 'team', + getGovernanceProposal: getGovernanceProposalMethod.bind(proposalStore as never), + listGovernanceProposalVotes: async () => [], + listGovernanceEvents: async () => [], + governanceProposalReadiness: async () => ({ contentReady: true }), + getGovernanceDecision: async () => null, + }; + const service = createGovernanceService(store, { create: async () => ({}) }); + const principal = { id: 'reader', roles: ['member'] }; + + const detail = await service.proposal(principal, 'project', 'proposal'); + expect.soft(detail.executionPlan).toEqual(authoredPlan); + const operation = createGovernanceOperations({ governance: service }).find(candidate => + candidate.binding.descriptor.operationId === 'governance.proposals.show')!; + const rest = operation.binding.descriptor.rest!; + const route = rest.path.replace('{projectId}', ':projectId').replace('{proposalId}', ':proposalId'); + const app = new Hono(); + app.on(rest.method, route, createOperationHttpHandler(operation, async () => ({ token: 'test-token', + clientId: 'client', scopes: [...operation.binding.descriptor.oauthScopes], + extra: { principal: { id: 'reader', roles: ['member'] } }, + }), 'sha256:test')); + const response = await app.request(route.replace(':projectId', 'project').replace(':proposalId', 'proposal'), { + headers: { authorization: 'Bearer test-token' }, + }); + expect(response.status).toBe(200); + const envelope = await response.json() as { data: Record }; + expect(envelope.data.executionPlan).toEqual(authoredPlan); + if (detail.executionPlan) { + expect(detail.executionPlan.workItems[0]).not.toHaveProperty('requiredCapabilities'); + expect(detail.executionPlan.workItems[0].estimateRef).toEqual(item.estimateRef); + } + expect(detail).toMatchObject({ votes: [], events: [], readiness: { contentReady: true }, decision: null }); + + const withoutPlan = { ...definition }; + delete (withoutPlan as Record).executionPlan; + exact = await native.publish(withoutPlan); + const noPlanRow = await getGovernanceProposalMethod.call(proposalStore as never, 'proposal'); + const noPlanSource = await readExactProposal(native.store, noPlanRow!); + expect(noPlanSource.source).toBe(exact.source); + expect(noPlanSource.source).not.toContain('executionPlan:'); + await expect.soft(service.proposal(principal, 'project', 'proposal')).resolves.toMatchObject({ executionPlan: null }); + + await native.query("UPDATE governance_proposals SET metadata_json='{}' WHERE id='proposal'"); + await expect.soft(service.proposal(principal, 'project', 'proposal')).rejects.toMatchObject({ code: 'proposal_exact_source_missing' }); + await native.query('UPDATE governance_proposals SET metadata_json=? WHERE id=?', [JSON.stringify({ contentProvenance: { + repositoryId: 'repository', contentPath: 'proposals/proposal.mdx', commitSha: exact.commit, digest: exact.digest } }), 'proposal']); + await native.query('UPDATE governance_proposals SET active_version=0 WHERE id=?', ['proposal']); + const wrongVersionRow = await getGovernanceProposalMethod.call(proposalStore as never, 'proposal'); + await expect(readExactProposal(native.store, wrongVersionRow!)).rejects.toMatchObject({ code: 'proposal_exact_source_missing' }); + await expect.soft(service.proposal(principal, 'project', 'proposal')).rejects.toMatchObject({ code: 'proposal_exact_source_missing' }); + await native.query('UPDATE governance_proposals SET active_version=1 WHERE id=?', ['proposal']); + + for (const fault of ['denied', 'moved'] as const) { + native.setFault(fault); + await expect.soft(service.proposal(principal, 'project', 'proposal')).rejects.toThrow(); + } + native.setFault('none'); + await native.query('UPDATE governance_proposals SET active_content_hash=? WHERE id=?', ['f'.repeat(64), 'proposal']); + await expect.soft(service.proposal(principal, 'project', 'proposal')).rejects.toMatchObject({ code: 'proposal_digest_mismatch' }); + await native.query('UPDATE governance_proposals SET active_content_hash=? WHERE id=?', [exact.digest, 'proposal']); + const invalid = { ...definition, executionPlan: { workItems: [] } }; + exact = await native.publish(invalid); + await native.query('UPDATE governance_proposals SET active_content_hash=?,metadata_json=? WHERE id=?', [exact.digest, + JSON.stringify({ contentProvenance: { repositoryId: 'repository', contentPath: 'proposals/proposal.mdx', commitSha: exact.commit, digest: exact.digest } }), 'proposal']); + const invalidRow = await getGovernanceProposalMethod.call(proposalStore as never, 'proposal'); + await expect(readExactProposal(native.store, invalidRow!)).rejects.toMatchObject({ status: 422, code: 'proposal_execution_plan_invalid' }); + await expect.soft(service.proposal(principal, 'project', 'proposal')).rejects.toMatchObject({ + status: 422, code: 'proposal_execution_plan_invalid', + }); + await expect.soft(service.proposal({ id: 'outsider' }, 'project', 'proposal')).rejects.toMatchObject({ + status: 403, code: 'project_access_denied', + }); + } finally { + await native.close(); + } + }); +}); 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..e3ea515ee --- /dev/null +++ b/tests/unit/control-plane/knowledge/inbox/question-scope.test.ts @@ -0,0 +1,77 @@ +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() })); + +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' }: { allowedPaths?: string[]; mode?: 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: 'workspace', 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('workspace'); +}); + +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('workspace'); +}); + +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/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]) {