diff --git a/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs b/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs index 8fd4bd8..5f2275d 100644 --- a/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs +++ b/src/apps/tracker-api/Tracker.Application/DependencyInjection.cs @@ -45,6 +45,11 @@ public static IServiceCollection AddApplication(this IServiceCollection services // Custom-field ACL (T-031 §4.4): validates inbound custom fields against the tenant's schema. services.AddScoped(); + // Core artifact contract source (T-036 §5 / plan item 6): the seam for where the phase + // artifact profiles come from. Today it is the provisional Tracker-held stand-in (GAP-004); + // swap this single registration for a Core-sync source once Evolith Core publishes the catalog. + services.AddSingleton(); + // Intake hardening: UMS RACI resolution + handshake credential verification. services.AddSingleton(); services.AddScoped(); diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs new file mode 100644 index 0000000..878c383 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/IPhaseArtifactProfileSource.cs @@ -0,0 +1,58 @@ +namespace Tracker.Application.Governance.ArtifactFieldSchema; + +/// +/// Provenance of the phase-artifact profile set (ADR T-036 §5). Tracker must source the Core +/// artifact contract from Evolith Core; until Core exposes it (GAP-004) Tracker serves a +/// Tracker-held stand-in that is explicitly marked as a provisional mirror of the Core standard. +/// +public static class PhaseArtifactProfileSourceKind +{ + /// Provisional Tracker-held mirror of the Core standard (GAP-004). Replaced by sync. + public const string StandIn = "core-standin"; + + /// Synced from the authoritative Evolith Core catalog (GAP-004 resolved). + public const string CoreSync = "core-sync"; +} + +/// +/// The seam that decouples Tracker from where the Core phase-artifact contract comes from +/// (ADR T-036 §5 / plan item 6). Today the only implementation is the provisional stand-in +/// (); when Evolith Core publishes the catalog +/// (GAP-004 resolved) a Core-sync implementation replaces the DI registration — nothing else in +/// the gate-governance stack changes. +/// +public interface IPhaseArtifactProfileSource +{ + /// Provenance of the served profiles (see ). + string Source { get; } + + /// The Core-authoritative base artifacts per SDLC phase. + IReadOnlyList GetProfiles(); +} + +/// +/// Provisional Core mirror (GAP-004): serves as the stand-in +/// while Evolith Core is unreachable. Every profile is stamped core-standin so consumers can +/// flag it as not-yet-synced. Swap for a Core-sync source once the Core contract endpoint exists. +/// +internal sealed class StandInPhaseArtifactProfileSource : IPhaseArtifactProfileSource +{ + public string Source => PhaseArtifactProfileSourceKind.StandIn; + + public IReadOnlyList GetProfiles() => + PhaseArtifactCatalog.Phases + .Select(phase => new PhaseArtifactProfileDto + { + Phase = phase, + Source = PhaseArtifactProfileSourceKind.StandIn, + Artifacts = PhaseArtifactCatalog.ByPhase[phase] + .Select(a => new PhaseArtifactDto + { + ArtifactKind = a.ArtifactKind, + Label = a.Label, + Required = a.Required, + }) + .ToList(), + }) + .ToList(); +} diff --git a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs index f93c7d4..89b197c 100644 --- a/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs +++ b/src/apps/tracker-api/Tracker.Application/Governance/ArtifactFieldSchema/PhaseArtifactCatalog.cs @@ -77,6 +77,14 @@ public sealed class PhaseArtifactDto public sealed class PhaseArtifactProfileDto { public string Phase { get; init; } = string.Empty; + + /// + /// Provenance of this profile set (ADR T-036 §5): core-standin (provisional Tracker + /// mirror, GAP-004) or core-sync (synced from Evolith Core). Lets consumers flag a + /// not-yet-synced catalog. See . + /// + public string Source { get; init; } = PhaseArtifactProfileSourceKind.StandIn; + public IReadOnlyList Artifacts { get; init; } = new List(); } @@ -86,23 +94,13 @@ public sealed record GetPhaseArtifactProfilesQuery() : IQuery> { + // T-036 §5 / plan item 6: read through the Core-source seam, not the static catalog directly, + // so the provisional stand-in can be swapped for a Core-sync source with no handler change. + private readonly IPhaseArtifactProfileSource _source; + + public GetPhaseArtifactProfilesQueryHandler(IPhaseArtifactProfileSource source) => _source = source; + public Task> Handle( GetPhaseArtifactProfilesQuery request, CancellationToken cancellationToken) - { - var result = PhaseArtifactCatalog.Phases - .Select(phase => new PhaseArtifactProfileDto - { - Phase = phase, - Artifacts = PhaseArtifactCatalog.ByPhase[phase] - .Select(a => new PhaseArtifactDto - { - ArtifactKind = a.ArtifactKind, - Label = a.Label, - Required = a.Required, - }) - .ToList(), - }) - .ToList(); - return Task.FromResult>(result); - } + => Task.FromResult(_source.GetProfiles()); } diff --git a/src/apps/tracker-web/src/api/types.ts b/src/apps/tracker-web/src/api/types.ts index 5b27cd0..cfc74e5 100644 --- a/src/apps/tracker-web/src/api/types.ts +++ b/src/apps/tracker-web/src/api/types.ts @@ -265,6 +265,11 @@ export interface PhaseArtifactDto { /** GET /api/phase-artifact-profiles — Core base artifacts per phase (PRD required in Discovery, …). */ export interface PhaseArtifactProfileDto { phase: string; + /** + * Provenance of the catalog (ADR T-036 §5): 'core-standin' = provisional Tracker mirror while + * Evolith Core is unreachable (GAP-004); 'core-sync' = synced from Core. Absent on legacy responses. + */ + source?: string | null; artifacts: PhaseArtifactDto[]; } diff --git a/src/apps/tracker-web/src/components/gate/GatePolicyEditor.tsx b/src/apps/tracker-web/src/components/gate/GatePolicyEditor.tsx index 6cb03fd..9858e2d 100644 --- a/src/apps/tracker-web/src/components/gate/GatePolicyEditor.tsx +++ b/src/apps/tracker-web/src/components/gate/GatePolicyEditor.tsx @@ -52,7 +52,6 @@ export function defaultGatePolicy(phase: string): GatePolicyDto { } interface EditState { - mode: GateMode; requiredEvidence: RequiredEvidenceSpec[]; strategy: ApprovalStrategy; stages: ApprovalStages; @@ -74,14 +73,14 @@ const EditPolicyBody: React.FC<{ // T-036: required evidence is anchored to the Core-defined artifacts of this // phase — never free text. The picker only offers this phase's Core artifacts. const profiles = usePhaseArtifactProfiles(); - const phaseArtifacts = React.useMemo( - () => profiles.data?.find((p) => p.phase === phase)?.artifacts ?? [], + const phaseProfile = React.useMemo( + () => profiles.data?.find((p) => p.phase === phase), [profiles.data, phase], ); - const artifactLabel = React.useCallback( - (kind: string) => phaseArtifacts.find((a) => a.artifactKind === kind)?.label ?? kind, - [phaseArtifacts], - ); + const phaseArtifacts = phaseProfile?.artifacts ?? []; + // T-036 §5: the catalog may be a provisional Tracker-held stand-in (GAP-004) rather than a live + // Core sync. Surface that so the tenant knows this contract is not yet Core-authoritative. + const catalogIsStandIn = phaseProfile?.source === 'core-standin'; // T-036 §3.5: criteria evaluate an artifact's fields; the artifact source is // phase-dependent (intake → the opportunity; phase gates → a Core deliverable // of the phase). The `fieldPath` is picked from the artifact's field catalog @@ -113,7 +112,6 @@ const EditPolicyBody: React.FC<{ [schemas.data], ); const [st, setSt] = React.useState(() => ({ - mode: (policy.mode as GateMode) ?? 'SIMPLE', requiredEvidence: policy.requiredEvidence.map((e) => ({ ...e })), strategy: (policy.approvalPolicy?.strategy as ApprovalStrategy) ?? 'SINGLE', stages: (policy.approvalPolicy?.stages as ApprovalStages) ?? 'sequential', @@ -122,8 +120,6 @@ const EditPolicyBody: React.FC<{ criteria: (policy.evaluationCriteria ?? []).map((c) => ({ ...c, expected: [...(c.expected ?? [])] })), })); - const [newEvType, setNewEvType] = React.useState(''); - const addCriterion = () => setSt((s) => ({ ...s, @@ -149,24 +145,19 @@ const EditPolicyBody: React.FC<{ const removeCriterion = (i: number) => setSt((s) => ({ ...s, criteria: s.criteria.filter((_, idx) => idx !== i) })); - const addEvidence = () => { - const type = newEvType.trim(); - if (!type) return; - if (st.requiredEvidence.some((e) => e.type === type)) { - toast.err(t('That evidence type is already required', 'Ese tipo de evidencia ya es requerido')); - return; - } - setSt((s) => ({ ...s, requiredEvidence: [...s.requiredEvidence, { type, mandatory: true }] })); - setNewEvType(''); - }; - const removeEvidence = (type: string) => - setSt((s) => ({ ...s, requiredEvidence: s.requiredEvidence.filter((e) => e.type !== type) })); - const toggleMandatory = (type: string) => + // T-036 §2/§3 (design points 2-3): required evidence is the projection of the + // phase's Core artifacts with their EFFECTIVE required state, not a free list. + // effectiveRequired = coreRequired OR tenantRequired. A Core-required artifact + // is always required (locked); a Core-optional one can be raised by the tenant. + // `st.requiredEvidence` holds only the TENANT overrides (Core-required artifacts + // are implicit); on save we persist the full effective set. + const isTenantRequired = (kind: string) => st.requiredEvidence.some((e) => e.type === kind); + const toggleRequired = (kind: string) => setSt((s) => ({ ...s, - requiredEvidence: s.requiredEvidence.map((e) => - e.type === type ? { ...e, mandatory: !e.mandatory } : e, - ), + requiredEvidence: s.requiredEvidence.some((e) => e.type === kind) + ? s.requiredEvidence.filter((e) => e.type !== kind) + : [...s.requiredEvidence, { type: kind, mandatory: true }], })); const addApprover = () => @@ -182,14 +173,43 @@ const EditPolicyBody: React.FC<{ const removeApprover = (i: number) => setSt((s) => ({ ...s, approvers: s.approvers.filter((_, idx) => idx !== i) })); + // T-036 §2.1: GateMode is DERIVED from the effective policy, never toggled. + // SIMPLE = a single approver with no required evidence, no blocking criterion and + // no Core verdict. COMPLEX = anything beyond that. We compute it here so the editor + // can show it read-only and the save payload can send the derived value to Core. + // `effectiveRequiredKinds` mirrors the save projection (Core-required ∪ tenant + // overrides), falling back to raw overrides when the Core profile is unavailable. + const effectiveRequiredKinds = phaseArtifacts.length + ? phaseArtifacts.filter((a) => a.required || isTenantRequired(a.artifactKind)).map((a) => a.artifactKind) + : st.requiredEvidence.map((e) => e.type); + const modeReasons = [ + effectiveRequiredKinds.length > 0 && t('required evidence', 'evidencia requerida'), + st.criteria.some((c) => c.severity === 'blocking' && c.fieldPath.trim() && c.label.trim()) && + t('a blocking criterion', 'un criterio blocking'), + st.strategy !== 'SINGLE' && t('a multi-approver strategy', 'una estrategia multi-aprobador'), + new Set(st.approvers.filter((a) => a.roleOrIdentity.trim()).map((a) => Number(a.stage) || 1)).size > 1 && + t('multiple approval stages', 'múltiples etapas de aprobación'), + st.requiresCoreVerdict && t('a required Core verdict', 'un verdict de Core requerido'), + ].filter(Boolean) as string[]; + const derivedMode: GateMode = modeReasons.length ? 'COMPLEX' : 'SIMPLE'; + const save = async () => { + // Persist the EFFECTIVE required set = Core-required artifacts ∪ tenant + // overrides, each projected as {type: artifactKind, mandatory}. When the Core + // profile is unavailable (empty), fall back to the raw tenant overrides so a + // transient load failure never silently wipes evidence (T-036 §2, design pt 2). + const effectiveEvidence = phaseArtifacts.length + ? phaseArtifacts + .filter((a) => a.required || isTenantRequired(a.artifactKind)) + .map((a) => ({ type: a.artifactKind, mandatory: true })) + : st.requiredEvidence; try { await upsert.mutateAsync({ phase, body: { tenantId, - mode: st.mode, - requiredEvidence: st.requiredEvidence, + mode: derivedMode, + requiredEvidence: effectiveEvidence, // The backend binds a FLAT approval shape (strategy/stages/approvers). strategy: st.strategy, stages: st.stages, @@ -247,28 +267,27 @@ const EditPolicyBody: React.FC<{ onSubmit={save} onClose={onClose} > - {/* Mode toggle */} + {/* Gate mode — DERIVED (read-only) per T-036 §2.1: the mode follows the + effective policy, it is no longer a hand-set toggle. */}
-
- {(['SIMPLE', 'COMPLEX'] as GateMode[]).map((m) => ( - - ))} +
+ + {derivedMode} + + + {derivedMode === 'COMPLEX' + ? t(`Complex because it has ${modeReasons.join(', ')}.`, `Complejo porque tiene ${modeReasons.join(', ')}.`) + : t('A single approval clears this gate.', 'Una sola aprobación libera este gate.')} +
@@ -280,53 +299,21 @@ const EditPolicyBody: React.FC<{ {t('Required evidence', 'Evidencia requerida')} {t( - 'Artifacts a submission must attach before this gate can be approved (e.g. test-results, business-case). Mandatory evidence blocks approval until attached; optional evidence is recorded but not blocking.', - 'Artefactos que una submission debe adjuntar antes de aprobar este gate (p.ej. test-results, business-case). La evidencia obligatoria bloquea la aprobación hasta adjuntarla; la opcional se registra pero no bloquea.', + "The phase's Core deliverable artifacts. Each is either required or not — there is no free-text evidence. Core-required artifacts are always required and locked; you may raise a Core-optional artifact to required for this tenant. Effectively-required artifacts must be attached before the gate can be approved.", + "Los artefactos deliverable del Core de la fase. Cada uno es requerido o no — no hay evidencia de texto libre. Los artefactos Core-requeridos siempre son requeridos y están bloqueados; puedes subir un artefacto Core-opcional a requerido para este tenant. Los artefactos efectivamente requeridos deben adjuntarse antes de aprobar el gate.", )} -
- {st.requiredEvidence.length === 0 && ( - - {t('No evidence required.', 'No se requiere evidencia.')} - - )} - {st.requiredEvidence.map((e) => ( -
- - {artifactLabel(e.type)} - {e.type} - - - -
- ))} -
+ {catalogIsStandIn && phaseArtifacts.length > 0 && ( + // T-036 §5: mark the provisional stand-in clearly (GAP-004) until Core sync lands. +

+ + {t( + 'Provisional Core catalog — mirrored in Tracker until Evolith Core sync is available.', + 'Catálogo Core provisional — reflejado en Tracker hasta que esté disponible la sincronización con Evolith Core.', + )} +

+ )} {phaseArtifacts.length === 0 ? (

{profiles.isLoading @@ -337,30 +324,58 @@ const EditPolicyBody: React.FC<{ )}

) : ( -
-
-