diff --git a/build/cli/index.js b/build/cli/index.js index addfe08bd..10462212d 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -46430,6 +46430,40 @@ function calculateReviewersStillNeeded(desiredCount, currentCount, confirmedCoun } +/***/ }), + +/***/ 81053: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.sddAnalysisStatusMarker = sddAnalysisStatusMarker; +exports.renderSddAnalysisStatus = renderSddAnalysisStatus; +function sddAnalysisStatusMarker(issueNumber) { + return ``; +} +const reasons = { + 'update-owner-mismatch': ['The update path does not belong to the selected catalog owner.', 'La ruta de actualización no pertenece al propietario seleccionado del catálogo.'], + 'companion-owner-unknown': ['The companion owner is absent from the catalog.', 'El propietario del SDD complementario no existe en el catálogo.'], + 'companion-path-registered': ['The companion path is already registered in the catalog.', 'La ruta del SDD complementario ya está registrada en el catálogo.'], + 'new-owner-registered': ['The proposed new capability already exists in the catalog.', 'La capacidad propuesta como nueva ya existe en el catálogo.'], + 'new-path-registered': ['The proposed new SDD path is already registered in the catalog.', 'La ruta propuesta como nueva ya está registrada en el catálogo.'], +}; +/** Stateless diagnostic, deliberately separate from the validated readiness record. */ +function renderSddAnalysisStatus(issueNumber, locale, reason) { + const spanish = /^es(?:-|$)/i.test(locale ?? ''); + const marker = sddAnalysisStatusMarker(issueNumber); + if (!reason) + return spanish + ? `## Estado del análisis del SDD\n\nEl análisis posterior ha validado la propiedad. La publicación y la preparación de la rama siguen pendientes de sus comprobaciones.\n\n${marker}` + : `## SDD analysis status\n\nA subsequent analysis validated ownership. Publication and branch readiness remain subject to their checks.\n\n${marker}`; + return spanish + ? `## Estado del SDD: análisis bloqueado\n\nNo se ha preparado ningún borrador ni publicación nueva. ${reasons[reason][1]}\n\n**Acción de mantenimiento:** Aclara en la misma issue el ID de capacidad del catálogo y la ruta: una actualización usa una ruta de ese propietario; un complemento usa un propietario existente y una ruta nueva; una capacidad nueva necesita ID y ruta sin registrar. Después vuelve a ejecutar el workflow original. Cada ejecución hace un solo análisis; no hay reparación ni reintento automático. Se conservan las respuestas, los SDD y las publicaciones anteriores.\n\n**Diagnóstico:** \`${reason}\`. La decisión rechazada no autoriza una rama ni la etiqueta de preparación.\n\n${marker}` + : `## SDD work status: analysis blocked\n\nNo draft or new publication was prepared. ${reasons[reason][0]}\n\n**Maintainer action:** Clarify the catalog capability ID and path in this same issue: an update uses a path owned by that capability; a companion uses an existing owner and a new path; a new capability needs an unregistered ID and path. Then rerun the original workflow. Each invocation performs one analysis; there is no automatic repair or retry. Existing answers, SDDs and publications are retained.\n\n**Diagnostic:** \`${reason}\`. The rejected decision authorizes no branch or readiness label.\n\n${marker}`; +} + + /***/ }), /***/ 2230: @@ -60716,6 +60750,7 @@ const issue_start_policy_1 = __nccwpck_require__(90332); const pre_branch_sdd_1 = __nccwpck_require__(34730); const recommendation_policy_1 = __nccwpck_require__(39410); const logging_ports_1 = __nccwpck_require__(6152); +const sdd_analysis_status_policy_1 = __nccwpck_require__(81053); const application_error_1 = __nccwpck_require__(75999); const pre_branch_sdd_draft_workflow_1 = __nccwpck_require__(93454); const ANALYSIS_SCHEMA = { @@ -60838,11 +60873,21 @@ class PreBranchSddGateUseCase { }); const analysisValue = asRecord(analysis); const owners = new Map(snapshot.capabilities.map(capability => [capability.id, capability.specs])); - const plan = (0, pre_branch_sdd_1.parseSddPlan)(analysisValue, owners); + let plan; + try { + plan = (0, pre_branch_sdd_1.parseSddPlan)(analysisValue, owners); + } + catch (error) { + if (!(error instanceof pre_branch_sdd_1.SddOwnershipFailure)) + throw error; + await this.writeAnalysisStatus(context, allComments, error.reason); + throw new application_error_1.ApplicationError('provider.contract-invalid', `${error.message} Clarify the catalog owner/path in the same issue and rerun the original workflow. No automatic retry was attempted.`); + } if (card?.record.branchName && (plan.action !== 'update' || plan.path !== card.record.plan.path || plan.capabilityId !== card.record.plan.capabilityId)) { throw new Error('An existing linked branch can only revise its owning SDD on the same path.'); } + await this.writeAnalysisStatus(context, allComments); const round = publicationRetry ? card.record.round : card?.record.phase === 'awaiting-answer' && card.record.issueDigest === digest && !staleAwaiting ? card.record.round + (plan.questions.length > 0 ? 1 : 0) : 1; @@ -60954,6 +60999,20 @@ class PreBranchSddGateUseCase { else await this.comments.updateComment(issueNumber, cardId, body); } + async writeAnalysisStatus(context, comments, reason) { + const marker = (0, sdd_analysis_status_policy_1.sddAnalysisStatusMarker)(context.issueNumber); + const prior = comments.filter(comment => comment.user?.login?.toLowerCase() === context.tokenUser.toLowerCase() + && comment.body?.includes(marker)).sort((a, b) => b.id - a.id)[0]; + if (!reason && !prior) + return; + const body = (0, sdd_analysis_status_policy_1.renderSddAnalysisStatus)(context.issueNumber, context.issueLocale, reason); + if (prior) { + if (prior.body !== body) + await this.comments.updateComment(context.issueNumber, prior.id, body); + } + else + await this.comments.addComment(context.issueNumber, body); + } async assertFresh(context, expectedBaseSha, sourceBranch) { const [liveBody, liveTitle, snapshot] = await Promise.all([ this.descriptions.getDescription(context.issueNumber), @@ -91943,7 +92002,7 @@ function parsePositiveSafeInteger(value) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.SDD_REQUIRED_HEADINGS = exports.SddOwnerFormatFailure = exports.SddDraftFormatFailure = exports.SDD_MAX_UPDATE_CHARACTERS = exports.SDD_MAX_DRAFT_CHARACTERS = exports.SDD_MIN_DRAFT_CHARACTERS = exports.SDD_GATE_MARKER = void 0; +exports.SDD_REQUIRED_HEADINGS = exports.SddOwnerFormatFailure = exports.SddDraftFormatFailure = exports.SddOwnershipFailure = exports.SDD_MAX_UPDATE_CHARACTERS = exports.SDD_MAX_DRAFT_CHARACTERS = exports.SDD_MIN_DRAFT_CHARACTERS = exports.SDD_GATE_MARKER = void 0; exports.retainSddAnswers = retainSddAnswers; exports.normalizeSddIssueTitle = normalizeSddIssueTitle; exports.isSafeSddPath = isSafeSddPath; @@ -91981,6 +92040,15 @@ function normalizeSddIssueTitle(title) { function isSafeSddPath(path) { return SDD_PATH.test(path) && !['specs/CATALOG.md', 'specs/_template.md'].includes(path); } +/** Closed diagnostics contain no model-selected identifiers or provider text. */ +class SddOwnershipFailure extends Error { + constructor(reason) { + super(`SDD ownership analysis rejected (${reason}).`); + this.reason = reason; + this.name = 'SddOwnershipFailure'; + } +} +exports.SddOwnershipFailure = SddOwnershipFailure; /** The agent may propose ownership but cannot invent a catalogued owner or arbitrary path. */ function parseSddPlan(value, catalog) { if (!isRecord(value)) @@ -91998,14 +92066,16 @@ function parseSddPlan(value, catalog) { const ownerPaths = catalog.get(capabilityId); const registered = [...catalog.values()].some(paths => paths.includes(path)); if (action === 'update' && (!ownerPaths?.includes(path) || !registered)) { - throw new Error('The requested SDD update is not owned by the selected catalog capability.'); - } - if (action === 'companion' && (!ownerPaths || registered)) { - throw new Error('A companion SDD requires an existing owner and a new path.'); - } - if (action === 'new' && (ownerPaths || registered)) { - throw new Error('A new SDD requires an unregistered capability and path.'); - } + throw new SddOwnershipFailure('update-owner-mismatch'); + } + if (action === 'companion' && !ownerPaths) + throw new SddOwnershipFailure('companion-owner-unknown'); + if (action === 'companion' && registered) + throw new SddOwnershipFailure('companion-path-registered'); + if (action === 'new' && ownerPaths) + throw new SddOwnershipFailure('new-owner-registered'); + if (action === 'new' && registered) + throw new SddOwnershipFailure('new-path-registered'); if (!Array.isArray(value.questions) || value.questions.length > 8) { throw new Error('The SDD analysis must contain at most eight blocking questions.'); } diff --git a/build/github_action/index.js b/build/github_action/index.js index 9cd6d5ace..66263e0b3 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -47972,6 +47972,40 @@ function calculateReviewersStillNeeded(desiredCount, currentCount, confirmedCoun } +/***/ }), + +/***/ 81053: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.sddAnalysisStatusMarker = sddAnalysisStatusMarker; +exports.renderSddAnalysisStatus = renderSddAnalysisStatus; +function sddAnalysisStatusMarker(issueNumber) { + return ``; +} +const reasons = { + 'update-owner-mismatch': ['The update path does not belong to the selected catalog owner.', 'La ruta de actualización no pertenece al propietario seleccionado del catálogo.'], + 'companion-owner-unknown': ['The companion owner is absent from the catalog.', 'El propietario del SDD complementario no existe en el catálogo.'], + 'companion-path-registered': ['The companion path is already registered in the catalog.', 'La ruta del SDD complementario ya está registrada en el catálogo.'], + 'new-owner-registered': ['The proposed new capability already exists in the catalog.', 'La capacidad propuesta como nueva ya existe en el catálogo.'], + 'new-path-registered': ['The proposed new SDD path is already registered in the catalog.', 'La ruta propuesta como nueva ya está registrada en el catálogo.'], +}; +/** Stateless diagnostic, deliberately separate from the validated readiness record. */ +function renderSddAnalysisStatus(issueNumber, locale, reason) { + const spanish = /^es(?:-|$)/i.test(locale ?? ''); + const marker = sddAnalysisStatusMarker(issueNumber); + if (!reason) + return spanish + ? `## Estado del análisis del SDD\n\nEl análisis posterior ha validado la propiedad. La publicación y la preparación de la rama siguen pendientes de sus comprobaciones.\n\n${marker}` + : `## SDD analysis status\n\nA subsequent analysis validated ownership. Publication and branch readiness remain subject to their checks.\n\n${marker}`; + return spanish + ? `## Estado del SDD: análisis bloqueado\n\nNo se ha preparado ningún borrador ni publicación nueva. ${reasons[reason][1]}\n\n**Acción de mantenimiento:** Aclara en la misma issue el ID de capacidad del catálogo y la ruta: una actualización usa una ruta de ese propietario; un complemento usa un propietario existente y una ruta nueva; una capacidad nueva necesita ID y ruta sin registrar. Después vuelve a ejecutar el workflow original. Cada ejecución hace un solo análisis; no hay reparación ni reintento automático. Se conservan las respuestas, los SDD y las publicaciones anteriores.\n\n**Diagnóstico:** \`${reason}\`. La decisión rechazada no autoriza una rama ni la etiqueta de preparación.\n\n${marker}` + : `## SDD work status: analysis blocked\n\nNo draft or new publication was prepared. ${reasons[reason][0]}\n\n**Maintainer action:** Clarify the catalog capability ID and path in this same issue: an update uses a path owned by that capability; a companion uses an existing owner and a new path; a new capability needs an unregistered ID and path. Then rerun the original workflow. Each invocation performs one analysis; there is no automatic repair or retry. Existing answers, SDDs and publications are retained.\n\n**Diagnostic:** \`${reason}\`. The rejected decision authorizes no branch or readiness label.\n\n${marker}`; +} + + /***/ }), /***/ 2230: @@ -58757,6 +58791,7 @@ const issue_start_policy_1 = __nccwpck_require__(90332); const pre_branch_sdd_1 = __nccwpck_require__(34730); const recommendation_policy_1 = __nccwpck_require__(39410); const logging_ports_1 = __nccwpck_require__(6152); +const sdd_analysis_status_policy_1 = __nccwpck_require__(81053); const application_error_1 = __nccwpck_require__(75999); const pre_branch_sdd_draft_workflow_1 = __nccwpck_require__(93454); const ANALYSIS_SCHEMA = { @@ -58879,11 +58914,21 @@ class PreBranchSddGateUseCase { }); const analysisValue = asRecord(analysis); const owners = new Map(snapshot.capabilities.map(capability => [capability.id, capability.specs])); - const plan = (0, pre_branch_sdd_1.parseSddPlan)(analysisValue, owners); + let plan; + try { + plan = (0, pre_branch_sdd_1.parseSddPlan)(analysisValue, owners); + } + catch (error) { + if (!(error instanceof pre_branch_sdd_1.SddOwnershipFailure)) + throw error; + await this.writeAnalysisStatus(context, allComments, error.reason); + throw new application_error_1.ApplicationError('provider.contract-invalid', `${error.message} Clarify the catalog owner/path in the same issue and rerun the original workflow. No automatic retry was attempted.`); + } if (card?.record.branchName && (plan.action !== 'update' || plan.path !== card.record.plan.path || plan.capabilityId !== card.record.plan.capabilityId)) { throw new Error('An existing linked branch can only revise its owning SDD on the same path.'); } + await this.writeAnalysisStatus(context, allComments); const round = publicationRetry ? card.record.round : card?.record.phase === 'awaiting-answer' && card.record.issueDigest === digest && !staleAwaiting ? card.record.round + (plan.questions.length > 0 ? 1 : 0) : 1; @@ -58995,6 +59040,20 @@ class PreBranchSddGateUseCase { else await this.comments.updateComment(issueNumber, cardId, body); } + async writeAnalysisStatus(context, comments, reason) { + const marker = (0, sdd_analysis_status_policy_1.sddAnalysisStatusMarker)(context.issueNumber); + const prior = comments.filter(comment => comment.user?.login?.toLowerCase() === context.tokenUser.toLowerCase() + && comment.body?.includes(marker)).sort((a, b) => b.id - a.id)[0]; + if (!reason && !prior) + return; + const body = (0, sdd_analysis_status_policy_1.renderSddAnalysisStatus)(context.issueNumber, context.issueLocale, reason); + if (prior) { + if (prior.body !== body) + await this.comments.updateComment(context.issueNumber, prior.id, body); + } + else + await this.comments.addComment(context.issueNumber, body); + } async assertFresh(context, expectedBaseSha, sourceBranch) { const [liveBody, liveTitle, snapshot] = await Promise.all([ this.descriptions.getDescription(context.issueNumber), @@ -82436,7 +82495,7 @@ function parsePositiveSafeInteger(value) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.SDD_REQUIRED_HEADINGS = exports.SddOwnerFormatFailure = exports.SddDraftFormatFailure = exports.SDD_MAX_UPDATE_CHARACTERS = exports.SDD_MAX_DRAFT_CHARACTERS = exports.SDD_MIN_DRAFT_CHARACTERS = exports.SDD_GATE_MARKER = void 0; +exports.SDD_REQUIRED_HEADINGS = exports.SddOwnerFormatFailure = exports.SddDraftFormatFailure = exports.SddOwnershipFailure = exports.SDD_MAX_UPDATE_CHARACTERS = exports.SDD_MAX_DRAFT_CHARACTERS = exports.SDD_MIN_DRAFT_CHARACTERS = exports.SDD_GATE_MARKER = void 0; exports.retainSddAnswers = retainSddAnswers; exports.normalizeSddIssueTitle = normalizeSddIssueTitle; exports.isSafeSddPath = isSafeSddPath; @@ -82474,6 +82533,15 @@ function normalizeSddIssueTitle(title) { function isSafeSddPath(path) { return SDD_PATH.test(path) && !['specs/CATALOG.md', 'specs/_template.md'].includes(path); } +/** Closed diagnostics contain no model-selected identifiers or provider text. */ +class SddOwnershipFailure extends Error { + constructor(reason) { + super(`SDD ownership analysis rejected (${reason}).`); + this.reason = reason; + this.name = 'SddOwnershipFailure'; + } +} +exports.SddOwnershipFailure = SddOwnershipFailure; /** The agent may propose ownership but cannot invent a catalogued owner or arbitrary path. */ function parseSddPlan(value, catalog) { if (!isRecord(value)) @@ -82491,14 +82559,16 @@ function parseSddPlan(value, catalog) { const ownerPaths = catalog.get(capabilityId); const registered = [...catalog.values()].some(paths => paths.includes(path)); if (action === 'update' && (!ownerPaths?.includes(path) || !registered)) { - throw new Error('The requested SDD update is not owned by the selected catalog capability.'); - } - if (action === 'companion' && (!ownerPaths || registered)) { - throw new Error('A companion SDD requires an existing owner and a new path.'); - } - if (action === 'new' && (ownerPaths || registered)) { - throw new Error('A new SDD requires an unregistered capability and path.'); - } + throw new SddOwnershipFailure('update-owner-mismatch'); + } + if (action === 'companion' && !ownerPaths) + throw new SddOwnershipFailure('companion-owner-unknown'); + if (action === 'companion' && registered) + throw new SddOwnershipFailure('companion-path-registered'); + if (action === 'new' && ownerPaths) + throw new SddOwnershipFailure('new-owner-registered'); + if (action === 'new' && registered) + throw new SddOwnershipFailure('new-path-registered'); if (!Array.isArray(value.questions) || value.questions.length > 8) { throw new Error('The SDD analysis must contain at most eight blocking questions.'); } diff --git a/docs/issues/pre-branch-sdds.mdx b/docs/issues/pre-branch-sdds.mdx index f2a2cfb77..4b25c54cc 100644 --- a/docs/issues/pre-branch-sdds.mdx +++ b/docs/issues/pre-branch-sdds.mdx @@ -118,6 +118,22 @@ Opening a PR replaces GitHub's direct issue-to-branch connection with its issue- Automatic updates retain the owning SDD from its original Git commit, including when Windows converts checkout line endings. +### Ownership analysis recovery + +If the analysis proposes an unknown companion owner or a path already registered +in the catalog, Copilot blocks before drafting and publishes an editable **SDD +work status: analysis blocked** card. The card identifies a closed failure +category without exposing the model's proposed values or raw response. + +Clarify the existing capability ID and intended SDD path in the same issue, then +rerun the original workflow. Updates use a path registered to that owner; +companions use an existing owner and a new path; new capabilities use a new ID +and path. Each invocation makes one analysis attempt with no automatic repair. +Existing answers, SDDs, branches and commits remain intact. A later valid analysis +updates the diagnostic; normal publication checks still determine readiness. +The diagnostic is separate from the validated question/publication record and +cannot authorize a branch or `branched` label. + ### Draft validation recovery Drafting aims for a complete compact document: about 6,000 characters for an diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 7729c582d..e935d9366 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -20,7 +20,7 @@ debt or convert unknown historic intent into a design decision. | `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes; inspect existing configuration and apply independent, scope-aware runtime adjustments with permission, drift and read-back verification | [Local web setup assistant](./local-web-setup-assistant.md) + 1 companion | 238 paths · 2026-10-21 | | `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT, verify bot identity and grants, recover explicitly from wrong-account input within the session, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 39 paths · 2026-10-07 | | `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 36 paths · 2026-10-18 | -| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 3 companion | 116 paths · 2026-10-19 | +| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 3 companion | 117 paths · 2026-10-21 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 71 paths · 2026-10-20 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 79 paths · 2026-10-11 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze its bounded diff partitions, publish stable findings atomically, and apply authorized verified fixes | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 2 companion | 109 paths · 2026-10-13 | @@ -147,11 +147,11 @@ debt or convert unknown historic intent into a design decision. ### `issue-start-and-sdd-readiness` — Uniform issue start and pre-branch SDD readiness - Owner: Copilot maintainers -- Last verified: 2026-10-19 +- Last verified: 2026-10-21 - Specifications: [`specs/issue-start-and-branch-readiness.md`](./issue-start-and-branch-readiness.md) · [`specs/pre-branch-sdd-gate.md`](./pre-branch-sdd-gate.md) · [`specs/pre-branch-sdd-diff.md`](./pre-branch-sdd-diff.md) · [`specs/pre-branch-sdd-status-readiness.md`](./pre-branch-sdd-status-readiness.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) - Entrypoints: [`action.yml`](../action.yml) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`src/actions/github_action.ts`](../src/actions/github_action.ts) -- Core code: [`src/domain/issue_workflow_runtime_policy.ts`](../src/domain/issue_workflow_runtime_policy.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/data/model/execution.ts`](../src/data/model/execution.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.ts) · [`src/application/policies/setup_issue_workflow_policy.ts`](../src/application/policies/setup_issue_workflow_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/infrastructure/git_commit_adapter.ts`](../src/infrastructure/git_commit_adapter.ts) · [`scripts/validate-specification-catalog.cjs`](../scripts/validate-specification-catalog.cjs) · [`src/domain/issue_start_policy.ts`](../src/domain/issue_start_policy.ts) · [`src/domain/pre_branch_sdd.ts`](../src/domain/pre_branch_sdd.ts) · [`src/application/ports/pre_branch_sdd_ports.ts`](../src/application/ports/pre_branch_sdd_ports.ts) · [`src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts`](../src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts) · [`src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts`](../src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts) · [`src/data/repository/branch/linked_branch_readiness_repository.ts`](../src/data/repository/branch/linked_branch_readiness_repository.ts) · [`src/infrastructure/pre_branch_sdd_workspace_adapter.ts`](../src/infrastructure/pre_branch_sdd_workspace_adapter.ts) · [`src/infrastructure/specification_catalog_validator.cjs`](../src/infrastructure/specification_catalog_validator.cjs) · [`src/actions/input_boolean_policy.ts`](../src/actions/input_boolean_policy.ts) · [`src/domain/issue_workflow_profile.ts`](../src/domain/issue_workflow_profile.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/domain/agent_response_length_failure.ts`](../src/domain/agent_response_length_failure.ts) · [`src/data/repository/agent_json_schema_validator.ts`](../src/data/repository/agent_json_schema_validator.ts) · [`src/application/ports/sdd_evidence_read_ports.ts`](../src/application/ports/sdd_evidence_read_ports.ts) · [`src/application/policies/sdd_issue_identity_policy.ts`](../src/application/policies/sdd_issue_identity_policy.ts) · [`src/application/policies/sdd_query_card_policy.ts`](../src/application/policies/sdd_query_card_policy.ts) · [`src/application/policies/sdd_status_policy.ts`](../src/application/policies/sdd_status_policy.ts) · [`src/application/usecases/sdd/sdd_evidence_query_use_case.ts`](../src/application/usecases/sdd/sdd_evidence_query_use_case.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/comment_automation_command_workflow.ts`](../src/application/usecases/comment_automation_command_workflow.ts) · [`src/application/usecases/comment_automation_contracts.ts`](../src/application/usecases/comment_automation_contracts.ts) · [`src/application/usecases/issue_comment_use_case.ts`](../src/application/usecases/issue_comment_use_case.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/infrastructure/composition/sdd_evidence_composition_root.ts`](../src/infrastructure/composition/sdd_evidence_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/domain/pre_branch_sdd_diff.ts`](../src/domain/pre_branch_sdd_diff.ts) · [`src/application/policies/sdd_diff_message_catalog.ts`](../src/application/policies/sdd_diff_message_catalog.ts) · [`src/application/policies/sdd_diff_presentation_policy.ts`](../src/application/policies/sdd_diff_presentation_policy.ts) · [`src/application/ports/sdd_diff_read_ports.ts`](../src/application/ports/sdd_diff_read_ports.ts) · [`src/application/usecases/sdd/sdd_diff_query_use_case.ts`](../src/application/usecases/sdd/sdd_diff_query_use_case.ts) · [`src/data/repository/sdd/sdd_diff_link_repository.ts`](../src/data/repository/sdd/sdd_diff_link_repository.ts) · [`src/infrastructure/composition/sdd_diff_composition_root.ts`](../src/infrastructure/composition/sdd_diff_composition_root.ts) · [`src/application/usecases/pull_request_review_comment_use_case.ts`](../src/application/usecases/pull_request_review_comment_use_case.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/domain/specification_catalog_path_policy.cjs`](../src/domain/specification_catalog_path_policy.cjs) · [`src/application/policies/github_runtime_doctor_policy.ts`](../src/application/policies/github_runtime_doctor_policy.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/domain/sdd_prerequisite_policy.cjs`](../src/domain/sdd_prerequisite_policy.cjs) +- Core code: [`src/domain/issue_workflow_runtime_policy.ts`](../src/domain/issue_workflow_runtime_policy.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/data/model/execution.ts`](../src/data/model/execution.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.ts) · [`src/application/policies/setup_issue_workflow_policy.ts`](../src/application/policies/setup_issue_workflow_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/infrastructure/git_commit_adapter.ts`](../src/infrastructure/git_commit_adapter.ts) · [`scripts/validate-specification-catalog.cjs`](../scripts/validate-specification-catalog.cjs) · [`src/domain/issue_start_policy.ts`](../src/domain/issue_start_policy.ts) · [`src/domain/pre_branch_sdd.ts`](../src/domain/pre_branch_sdd.ts) · [`src/application/ports/pre_branch_sdd_ports.ts`](../src/application/ports/pre_branch_sdd_ports.ts) · [`src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts`](../src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts) · [`src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts`](../src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts) · [`src/data/repository/branch/linked_branch_readiness_repository.ts`](../src/data/repository/branch/linked_branch_readiness_repository.ts) · [`src/infrastructure/pre_branch_sdd_workspace_adapter.ts`](../src/infrastructure/pre_branch_sdd_workspace_adapter.ts) · [`src/infrastructure/specification_catalog_validator.cjs`](../src/infrastructure/specification_catalog_validator.cjs) · [`src/actions/input_boolean_policy.ts`](../src/actions/input_boolean_policy.ts) · [`src/domain/issue_workflow_profile.ts`](../src/domain/issue_workflow_profile.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/domain/agent_response_length_failure.ts`](../src/domain/agent_response_length_failure.ts) · [`src/data/repository/agent_json_schema_validator.ts`](../src/data/repository/agent_json_schema_validator.ts) · [`src/application/ports/sdd_evidence_read_ports.ts`](../src/application/ports/sdd_evidence_read_ports.ts) · [`src/application/policies/sdd_issue_identity_policy.ts`](../src/application/policies/sdd_issue_identity_policy.ts) · [`src/application/policies/sdd_query_card_policy.ts`](../src/application/policies/sdd_query_card_policy.ts) · [`src/application/policies/sdd_status_policy.ts`](../src/application/policies/sdd_status_policy.ts) · [`src/application/usecases/sdd/sdd_evidence_query_use_case.ts`](../src/application/usecases/sdd/sdd_evidence_query_use_case.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/comment_automation_command_workflow.ts`](../src/application/usecases/comment_automation_command_workflow.ts) · [`src/application/usecases/comment_automation_contracts.ts`](../src/application/usecases/comment_automation_contracts.ts) · [`src/application/usecases/issue_comment_use_case.ts`](../src/application/usecases/issue_comment_use_case.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/infrastructure/composition/sdd_evidence_composition_root.ts`](../src/infrastructure/composition/sdd_evidence_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/domain/pre_branch_sdd_diff.ts`](../src/domain/pre_branch_sdd_diff.ts) · [`src/application/policies/sdd_diff_message_catalog.ts`](../src/application/policies/sdd_diff_message_catalog.ts) · [`src/application/policies/sdd_diff_presentation_policy.ts`](../src/application/policies/sdd_diff_presentation_policy.ts) · [`src/application/ports/sdd_diff_read_ports.ts`](../src/application/ports/sdd_diff_read_ports.ts) · [`src/application/usecases/sdd/sdd_diff_query_use_case.ts`](../src/application/usecases/sdd/sdd_diff_query_use_case.ts) · [`src/data/repository/sdd/sdd_diff_link_repository.ts`](../src/data/repository/sdd/sdd_diff_link_repository.ts) · [`src/infrastructure/composition/sdd_diff_composition_root.ts`](../src/infrastructure/composition/sdd_diff_composition_root.ts) · [`src/application/usecases/pull_request_review_comment_use_case.ts`](../src/application/usecases/pull_request_review_comment_use_case.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/domain/specification_catalog_path_policy.cjs`](../src/domain/specification_catalog_path_policy.cjs) · [`src/application/policies/github_runtime_doctor_policy.ts`](../src/application/policies/github_runtime_doctor_policy.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/domain/sdd_prerequisite_policy.cjs`](../src/domain/sdd_prerequisite_policy.cjs) · [`src/application/policies/sdd_analysis_status_policy.ts`](../src/application/policies/sdd_analysis_status_policy.ts) - Tests: [`src/domain/__tests__/issue_workflow_runtime_policy.test.ts`](../src/domain/__tests__/issue_workflow_runtime_policy.test.ts) · [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/tooling/__tests__/validate_specification_catalog.test.ts`](../src/tooling/__tests__/validate_specification_catalog.test.ts) · [`src/domain/__tests__/issue_start_policy.test.ts`](../src/domain/__tests__/issue_start_policy.test.ts) · [`src/domain/__tests__/pre_branch_sdd.test.ts`](../src/domain/__tests__/pre_branch_sdd.test.ts) · [`src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts) · [`src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts`](../src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/actions/__tests__/input_boolean_policy.test.ts`](../src/actions/__tests__/input_boolean_policy.test.ts) · [`src/domain/__tests__/issue_workflow_profile.test.ts`](../src/domain/__tests__/issue_workflow_profile.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/data/repository/__tests__/agent_json_schema_validator.test.ts`](../src/data/repository/__tests__/agent_json_schema_validator.test.ts) · [`src/application/policies/__tests__/sdd_issue_identity_policy.test.ts`](../src/application/policies/__tests__/sdd_issue_identity_policy.test.ts) · [`src/application/policies/__tests__/sdd_query_card_policy.test.ts`](../src/application/policies/__tests__/sdd_query_card_policy.test.ts) · [`src/application/policies/__tests__/sdd_status_policy.test.ts`](../src/application/policies/__tests__/sdd_status_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts) · [`src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts`](../src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts) · [`src/domain/__tests__/pre_branch_sdd_diff.test.ts`](../src/domain/__tests__/pre_branch_sdd_diff.test.ts) · [`src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts`](../src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts) · [`src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts`](../src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts) · [`src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts) · [`src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts`](../src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts) · [`src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts`](../src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts) · [`src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts`](../src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts) · [`src/tooling/__tests__/portable_specification_catalog.test.ts`](../src/tooling/__tests__/portable_specification_catalog.test.ts) · [`src/application/policies/__tests__/github_runtime_doctor_policy.test.ts`](../src/application/policies/__tests__/github_runtime_doctor_policy.test.ts) · [`src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts`](../src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts`](../src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) - User documentation: [`docs/issues/branch-management.mdx`](../docs/issues/branch-management.mdx) · [`docs/issues/workflow-setup.mdx`](../docs/issues/workflow-setup.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/development/specifications.mdx`](../docs/development/specifications.mdx) · [`docs/issues/pre-branch-sdds.mdx`](../docs/issues/pre-branch-sdds.mdx) · [`docs/issues/index.mdx`](../docs/issues/index.mdx) · [`docs/issues/labels-and-branch-types.mdx`](../docs/issues/labels-and-branch-types.mdx) · [`docs/issues/configuration.mdx`](../docs/issues/configuration.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) diff --git a/specs/catalog.json b/specs/catalog.json index d2a52a9bb..95fed7d33 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1469,7 +1469,7 @@ "status": "implemented", "scope": "Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons", "owner": "Copilot maintainers", - "lastVerified": "2026-10-19", + "lastVerified": "2026-10-21", "specs": [ "specs/issue-start-and-branch-readiness.md", "specs/pre-branch-sdd-gate.md", @@ -1545,7 +1545,8 @@ "src/infrastructure/setup_workspace_adapter.ts", "src/application/usecases/setup/doctor_use_case.ts", "src/application/policies/setup_doctor_message_catalog.ts", - "src/domain/sdd_prerequisite_policy.cjs" + "src/domain/sdd_prerequisite_policy.cjs", + "src/application/policies/sdd_analysis_status_policy.ts" ], "tests": [ "src/domain/__tests__/issue_workflow_runtime_policy.test.ts", diff --git a/specs/pre-branch-sdd-gate.md b/specs/pre-branch-sdd-gate.md index 57b071ff5..43fa46a50 100644 --- a/specs/pre-branch-sdd-gate.md +++ b/specs/pre-branch-sdd-gate.md @@ -1085,3 +1085,58 @@ migration; adapted standards retain that rule in a numbered section. The canonical normative rule MUST occur inside one numbered section; concatenating fragments across section boundaries cannot establish it. Two additional adapter cases cover this boundary, included in the 91-case amendment floor. + + +### Safe rejected ownership and bounded recovery (2026-10-21, #636) + +Observed defect: semantic ownership validation could reject a successful model +response before any issue card, leaving only generic workflow failure. The gate +MUST preserve validation and classify update-owner mismatch, unknown companion +owner, registered companion path, registered new owner and registered new path +with closed typed reasons. When both companion constraints fail, unknown owner +has deterministic precedence. Generated owner/path values and response text MUST +NOT enter this diagnostic. The immutable snapshot and catalog remain authoritative. + +The recovery policy is **one analysis per invocation, zero automatic repairs**. +A maintainer clarifies the catalog ID/path in the same issue and reruns the +original workflow, which performs fresh analysis and unchanged freshness checks. +This amendment selects the maintainer-recovery option; invalid proposals never +become invented clarification plans or stored validated owners. Existing answers, +SDDs and published branches/commits are retained. A provider outage or malformed +shape remains its existing failure, not an ownership retry. + +```text +Analysis -> semantic rejection -> owned diagnostic -> maintainer clarification +-> normal rerun -> fresh valid analysis -> unchanged draft/publication checks +``` + +Text equivalent: a rejected decision blocks; a human clarifies the same issue; +a fresh invocation may progress only after normal semantic and publication checks. + +UI: **SDD work status: analysis blocked** explains that no new draft/publication +was prepared, gives the specific closed cause, then asks the maintainer to clarify +and rerun. English/Spanish copy uses the issue locale. An owned, issue-bound +`copilot:sdd-analysis-status:v1` diagnostic is reused and unchanged retries emit +no mutation. It has no readiness marker, no questions, proposed values, commands, +external URLs or new configuration. A later validated owner updates it to +“Publication and branch readiness remain subject to their checks.” Existing +pending/question, partial-publication and completed cards retain their contracts. +A foreign marker is never overwritten. Failed comment publication remains blocked. + +Domain owns typed semantic failures; the application presentation policy owns +closed localized content; the use case uses its existing bound comment port. +No SDK, filesystem, process or provider DTO crosses into either pure module. +Typecheck, architecture tests and fault-injected use-case tests enforce the +boundary. No credential or permission expansion, migration or new state schema. +Upgrade the Action, clarify the same issue and rerun; rollback loses this +additional diagnosis but cannot authorize invalid ownership. + +The additional budget is **22 distinct cases**: five typed domain classifications; +ten use-case reason/locale boundaries; seven retry, recovery, identity, provider, +malformed-analysis and comment-failure cases. Existing valid update/companion/new tests remain required. +Acceptance checks that invalid analysis makes no draft validation or publication +call, does not add `branched`, leaks no proposed identifiers or response, and is +not parsed as readiness. Documentation: ownership analysis recovery in the +pre-branch SDD guide. Definition of Done also requires existing global quality, +package and catalog gates, preserved safety checks and review of both locale +messages; tests do not claim a live consumer recovery. diff --git a/src/application/policies/sdd_analysis_status_policy.ts b/src/application/policies/sdd_analysis_status_policy.ts new file mode 100644 index 000000000..6ba15c49c --- /dev/null +++ b/src/application/policies/sdd_analysis_status_policy.ts @@ -0,0 +1,25 @@ +import type { SddOwnershipFailureReason } from '../../domain/pre_branch_sdd'; + +export function sddAnalysisStatusMarker(issueNumber: number): string { + return ``; +} + +const reasons: Record = { + 'update-owner-mismatch': ['The update path does not belong to the selected catalog owner.', 'La ruta de actualización no pertenece al propietario seleccionado del catálogo.'], + 'companion-owner-unknown': ['The companion owner is absent from the catalog.', 'El propietario del SDD complementario no existe en el catálogo.'], + 'companion-path-registered': ['The companion path is already registered in the catalog.', 'La ruta del SDD complementario ya está registrada en el catálogo.'], + 'new-owner-registered': ['The proposed new capability already exists in the catalog.', 'La capacidad propuesta como nueva ya existe en el catálogo.'], + 'new-path-registered': ['The proposed new SDD path is already registered in the catalog.', 'La ruta propuesta como nueva ya está registrada en el catálogo.'], +}; + +/** Stateless diagnostic, deliberately separate from the validated readiness record. */ +export function renderSddAnalysisStatus(issueNumber: number, locale: string | undefined, reason?: SddOwnershipFailureReason): string { + const spanish = /^es(?:-|$)/i.test(locale ?? ''); + const marker = sddAnalysisStatusMarker(issueNumber); + if (!reason) return spanish + ? `## Estado del análisis del SDD\n\nEl análisis posterior ha validado la propiedad. La publicación y la preparación de la rama siguen pendientes de sus comprobaciones.\n\n${marker}` + : `## SDD analysis status\n\nA subsequent analysis validated ownership. Publication and branch readiness remain subject to their checks.\n\n${marker}`; + return spanish + ? `## Estado del SDD: análisis bloqueado\n\nNo se ha preparado ningún borrador ni publicación nueva. ${reasons[reason][1]}\n\n**Acción de mantenimiento:** Aclara en la misma issue el ID de capacidad del catálogo y la ruta: una actualización usa una ruta de ese propietario; un complemento usa un propietario existente y una ruta nueva; una capacidad nueva necesita ID y ruta sin registrar. Después vuelve a ejecutar el workflow original. Cada ejecución hace un solo análisis; no hay reparación ni reintento automático. Se conservan las respuestas, los SDD y las publicaciones anteriores.\n\n**Diagnóstico:** \`${reason}\`. La decisión rechazada no autoriza una rama ni la etiqueta de preparación.\n\n${marker}` + : `## SDD work status: analysis blocked\n\nNo draft or new publication was prepared. ${reasons[reason][0]}\n\n**Maintainer action:** Clarify the catalog capability ID and path in this same issue: an update uses a path owned by that capability; a companion uses an existing owner and a new path; a new capability needs an unregistered ID and path. Then rerun the original workflow. Each invocation performs one analysis; there is no automatic repair or retry. Existing answers, SDDs and publications are retained.\n\n**Diagnostic:** \`${reason}\`. The rejected decision authorizes no branch or readiness label.\n\n${marker}`; +} diff --git a/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts b/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts index 7dd628025..f81d37acc 100644 --- a/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts +++ b/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts @@ -11,6 +11,7 @@ import type { BoundLinkedBranchReadinessPort } from '../../ports/linked_branch_r import { Result } from '../../../data/model/result'; import { SDD_REQUIRED_LABEL } from '../../../domain/issue_start_policy'; import { + SddOwnershipFailure, parseSddAnswer, parseSddPlan, readSddGateRecord, @@ -24,7 +25,8 @@ import { } from '../../../domain/pre_branch_sdd'; import { getVisibleIssueDescription } from '../../policies/recommendation_policy'; import { logError } from '../../ports/logging_ports'; -import { toApplicationError } from '../../errors/application_error'; +import { renderSddAnalysisStatus, sddAnalysisStatusMarker } from '../../policies/sdd_analysis_status_policy'; +import { ApplicationError, toApplicationError } from '../../errors/application_error'; import { draftSddMarkdown } from './pre_branch_sdd_draft_workflow'; export interface PreBranchSddContext { @@ -174,11 +176,19 @@ export class PreBranchSddGateUseCase { }); const analysisValue = asRecord(analysis); const owners = new Map(snapshot.capabilities.map(capability => [capability.id, capability.specs])); - const plan = parseSddPlan(analysisValue, owners); + let plan: SddPlan; + try { + plan = parseSddPlan(analysisValue, owners); + } catch (error) { + if (!(error instanceof SddOwnershipFailure)) throw error; + await this.writeAnalysisStatus(context, allComments, error.reason); + throw new ApplicationError('provider.contract-invalid', `${error.message} Clarify the catalog owner/path in the same issue and rerun the original workflow. No automatic retry was attempted.`); + } if (card?.record.branchName && (plan.action !== 'update' || plan.path !== card.record.plan.path || plan.capabilityId !== card.record.plan.capabilityId)) { throw new Error('An existing linked branch can only revise its owning SDD on the same path.'); } + await this.writeAnalysisStatus(context, allComments); const round = publicationRetry ? card.record.round : card?.record.phase === 'awaiting-answer' && card.record.issueDigest === digest && !staleAwaiting ? card.record.round + (plan.questions.length > 0 ? 1 : 0) : 1; @@ -284,6 +294,17 @@ export class PreBranchSddGateUseCase { else await this.comments.updateComment(issueNumber, cardId, body); } + private async writeAnalysisStatus(context: PreBranchSddContext, comments: readonly IssueCommentPublicationTarget[], reason?: SddOwnershipFailure['reason']): Promise { + const marker = sddAnalysisStatusMarker(context.issueNumber); + const prior = comments.filter(comment => comment.user?.login?.toLowerCase() === context.tokenUser.toLowerCase() + && comment.body?.includes(marker)).sort((a, b) => b.id - a.id)[0]; + if (!reason && !prior) return; + const body = renderSddAnalysisStatus(context.issueNumber, context.issueLocale, reason); + if (prior) { + if (prior.body !== body) await this.comments.updateComment(context.issueNumber, prior.id, body); + } else await this.comments.addComment(context.issueNumber, body); + } + private async assertFresh(context: PreBranchSddContext, expectedBaseSha: string, sourceBranch: string): Promise { const [liveBody, liveTitle, snapshot] = await Promise.all([ this.descriptions.getDescription(context.issueNumber), diff --git a/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts b/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts index 0f233820c..4cf54ffc7 100644 --- a/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts +++ b/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts @@ -601,3 +601,97 @@ describe('PreBranchSddGateUseCase', () => { expect(h.query).not.toHaveBeenCalled(); }); }); + + +describe('safe ownership diagnostics (#636)', () => { + const invalid = [ + ['update-owner-mismatch', { ...plan, path: 'specs/private-update.md' }], + ['companion-owner-unknown', { ...plan, action: 'companion', capabilityId: 'private-owner', path: 'specs/private-companion.md' }], + ['companion-path-registered', { ...plan, action: 'companion' }], + ['new-owner-registered', { ...plan, action: 'new', path: 'specs/private-new.md' }], + ['new-path-registered', { ...plan, action: 'new', capabilityId: 'private-owner' }], + ] as const; + it.each(invalid.flatMap(([code, decision]) => ['en-US', 'es-ES'].map(locale => ({ code, decision, locale }))))( + 'blocks $code in $locale without exposing generated data or publishing a draft', async ({ code, decision, locale }) => { + const h = harness(); + h.query.mockResolvedValue({ ...decision, reason: 'secret-provider-data '.repeat(4) }); + const outcome = await h.useCase.begin({ ...context(), issueLocale: locale }); + expect(outcome.status).toBe('blocked'); + expect(h.query).toHaveBeenCalledTimes(1); + expect(h.readSdd).not.toHaveBeenCalled(); + expect(h.validateDraft).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); + expect(h.labels()).not.toContain('branched'); + const body = h.comments[0].body; + expect(body).toContain(code); + expect(body).not.toMatch(/private-|secret-provider-data/); + expect(readSddGateRecord(body, 42)).toBeUndefined(); + expect(outcome.results[0].errors[0]).toMatchObject({ code: 'provider.contract-invalid' }); + }, + ); + it('reuses the owned diagnostic on retry and marks recovery without granting readiness', async () => { + const h = harness(); + h.query.mockResolvedValueOnce(invalid[1][1]).mockResolvedValueOnce(invalid[2][1]); + await h.useCase.begin(context()); + await h.useCase.begin(context()); + expect(h.addComment).toHaveBeenCalledTimes(1); + expect(h.updateComment).toHaveBeenCalledTimes(1); + h.query.mockResolvedValueOnce(plan).mockResolvedValueOnce({ markdown: '# Valid' }); + expect((await h.useCase.begin(context())).status).toBe('drafted'); + expect(h.comments[0].body).toContain('validated ownership'); + expect(h.labels()).not.toContain('branched'); + }); + it('does not overwrite a foreign diagnostic marker', async () => { + const h = harness(); + h.comments.push({ id: 99, body: '', user: { login: 'attacker' } }); + h.query.mockResolvedValue(invalid[1][1]); + await h.useCase.begin(context()); + expect(h.updateComment).not.toHaveBeenCalled(); + expect(h.addComment).toHaveBeenCalledTimes(1); + }); + it('does not classify an unrelated provider failure as rejected ownership or retry it', async () => { + const h = harness(); + h.query.mockRejectedValue(new Error('Provider unavailable')); + expect((await h.useCase.begin(context())).status).toBe('blocked'); + expect(h.query).toHaveBeenCalledTimes(1); + expect(h.addComment).not.toHaveBeenCalled(); + }); + it('withholds all publication when diagnostic publication itself fails', async () => { + const h = harness(); + h.query.mockResolvedValue(invalid[1][1]); + h.addComment.mockRejectedValue(new Error('Comment unavailable')); + expect((await h.useCase.begin(context())).status).toBe('blocked'); + expect(h.validateDraft).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); + }); +}); + + +it('keeps repeated identical ownership failures to one unchanged diagnostic card', async () => { + const h = harness(); + h.query.mockResolvedValue({ ...plan, action: 'companion', capabilityId: 'unknown' }); + await h.useCase.begin(context()); + await h.useCase.begin(context()); + expect(h.addComment).toHaveBeenCalledTimes(1); + expect(h.updateComment).not.toHaveBeenCalled(); +}); + +it('reports a later valid ownership analysis in Spanish without a readiness record', async () => { + const h = harness(); + h.query.mockResolvedValueOnce({ ...plan, action: 'companion', capabilityId: 'unknown' }); + await h.useCase.begin({ ...context(), issueLocale: 'es-ES' }); + h.query.mockResolvedValueOnce(plan).mockResolvedValueOnce({ markdown: '# Valid' }); + await h.useCase.begin({ ...context(), issueLocale: 'es-ES' }); + expect(h.comments[0].body).toContain('ha validado la propiedad'); + expect(readSddGateRecord(h.comments[0].body, 42)).toBeUndefined(); +}); + +it('keeps malformed analysis outside ownership recovery and publishes no draft or diagnostic', async () => { + const h = harness(); + h.query.mockResolvedValue({ ...plan, reason: 'short' }); + expect((await h.useCase.begin(context())).status).toBe('blocked'); + expect(h.query).toHaveBeenCalledTimes(1); + expect(h.addComment).not.toHaveBeenCalled(); + expect(h.validateDraft).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); +}); diff --git a/src/architecture/github_publication_mutation_baseline.json b/src/architecture/github_publication_mutation_baseline.json index 679c26090..07c361a51 100644 --- a/src/architecture/github_publication_mutation_baseline.json +++ b/src/architecture/github_publication_mutation_baseline.json @@ -20,9 +20,9 @@ }, { "file": "src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts", - "addComment": 1, - "updateComment": 1, - "reason": "Creates or updates one bot-owned SDD clarification and publication status card after issue and branch verification." + "addComment": 2, + "updateComment": 2, + "reason": "Creates or updates the verified bot-owned SDD clarification/publication card, plus one separate issue-bound ownership-analysis diagnostic; rejected plans never create a readiness record, and unchanged diagnostics emit no write." }, { "file": "src/application/usecases/steps/commit/bugbot/publish_issue_finding_comment.ts", diff --git a/src/domain/__tests__/pre_branch_sdd.test.ts b/src/domain/__tests__/pre_branch_sdd.test.ts index e8196c5cc..a13c1ffd6 100644 --- a/src/domain/__tests__/pre_branch_sdd.test.ts +++ b/src/domain/__tests__/pre_branch_sdd.test.ts @@ -11,6 +11,7 @@ import { normalizeSddIssueTitle, retainSddAnswers, SddOwnerFormatFailure, + SddOwnershipFailure, type SddGateRecord, } from '../pre_branch_sdd'; @@ -247,3 +248,15 @@ describe('retained SDD answer provenance', () => { expect(readSddGateRecord(rendered, 42)?.answers).toEqual([malicious]); }); }); + + +it.each([ + ['update-owner-mismatch', { action: 'update', path: 'specs/other.md' }], + ['companion-owner-unknown', { action: 'companion', capabilityId: 'unknown' }], + ['companion-path-registered', { action: 'companion' }], + ['new-owner-registered', { action: 'new' }], + ['new-path-registered', { action: 'new', capabilityId: 'unknown' }], +])('classifies ownership rejection %s without retaining proposed values', (reason, change) => { + try { parseSddPlan({ ...plan, ...(change as object) }, catalog); throw new Error('expected rejection'); } + catch (error) { expect(error).toBeInstanceOf(SddOwnershipFailure); expect(error).toMatchObject({ reason }); } +}); diff --git a/src/domain/pre_branch_sdd.ts b/src/domain/pre_branch_sdd.ts index d7b8d4120..7b1943f8c 100644 --- a/src/domain/pre_branch_sdd.ts +++ b/src/domain/pre_branch_sdd.ts @@ -73,6 +73,17 @@ export function isSafeSddPath(path: string): boolean { return SDD_PATH.test(path) && !['specs/CATALOG.md', 'specs/_template.md'].includes(path); } +export type SddOwnershipFailureReason = 'update-owner-mismatch' | 'companion-owner-unknown' + | 'companion-path-registered' | 'new-owner-registered' | 'new-path-registered'; + +/** Closed diagnostics contain no model-selected identifiers or provider text. */ +export class SddOwnershipFailure extends Error { + constructor(readonly reason: SddOwnershipFailureReason) { + super(`SDD ownership analysis rejected (${reason}).`); + this.name = 'SddOwnershipFailure'; + } +} + /** The agent may propose ownership but cannot invent a catalogued owner or arbitrary path. */ export function parseSddPlan(value: unknown, catalog: ReadonlyMap): SddPlan { if (!isRecord(value)) throw new Error('The SDD analysis must be an object.'); @@ -89,14 +100,12 @@ export function parseSddPlan(value: unknown, catalog: ReadonlyMap paths.includes(path)); if (action === 'update' && (!ownerPaths?.includes(path) || !registered)) { - throw new Error('The requested SDD update is not owned by the selected catalog capability.'); - } - if (action === 'companion' && (!ownerPaths || registered)) { - throw new Error('A companion SDD requires an existing owner and a new path.'); - } - if (action === 'new' && (ownerPaths || registered)) { - throw new Error('A new SDD requires an unregistered capability and path.'); + throw new SddOwnershipFailure('update-owner-mismatch'); } + if (action === 'companion' && !ownerPaths) throw new SddOwnershipFailure('companion-owner-unknown'); + if (action === 'companion' && registered) throw new SddOwnershipFailure('companion-path-registered'); + if (action === 'new' && ownerPaths) throw new SddOwnershipFailure('new-owner-registered'); + if (action === 'new' && registered) throw new SddOwnershipFailure('new-path-registered'); if (!Array.isArray(value.questions) || value.questions.length > 8) { throw new Error('The SDD analysis must contain at most eight blocking questions.'); }