diff --git a/apps/azure/src/components/ProjectsTabView.tsx b/apps/azure/src/components/ProjectsTabView.tsx index 9ae444b4b..bab9d7914 100644 --- a/apps/azure/src/components/ProjectsTabView.tsx +++ b/apps/azure/src/components/ProjectsTabView.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import type { ProcessHub, ControlRecord, ControlHandoff } from '@variscout/core'; +import type { + ProcessHub, + ControlRecord, + ControlHandoff, + ProcessParticipantRef, +} from '@variscout/core'; import type { ImprovementProject } from '@variscout/core/improvementProject'; import type { HubAction } from '@variscout/core/actions'; import type { ProjectMember } from '@variscout/core/projectMembership'; @@ -35,6 +40,23 @@ function liveProjects(hub: ProcessHub | undefined): ImprovementProject[] { return p && p.deletedAt === null ? [p] : []; } +/** + * Resolve the acting user as the sign-off approver. Sign-off is decoupled from + * the process owner (IM-7 Task 3): the approver is whoever is acting now, + * resolved from project membership by the signed-in user id, with a generic + * fallback when the user is unknown (no auth context in tests). + */ +function actingApprover( + project: ImprovementProject, + currentUserId: string | undefined +): ProcessParticipantRef { + const me = project.metadata.members?.find(m => m.userId === currentUserId); + if (me) { + return { userId: me.userId, displayName: me.displayName }; + } + return { userId: currentUserId, displayName: 'Reviewer' }; +} + function mergeProjectPatch( project: ImprovementProject, patch: Extract['patch'], @@ -152,9 +174,18 @@ const ProjectsTabView: React.FC = ({ actions={approachInputs?.actions} now={now} currentUserId={currentUserId} - onMembersChange={(members: ProjectMember[]) => - applyProjectPatch(selected, { metadata: { ...selected.metadata, members } }) - } + onMembersChange={(members: ProjectMember[]) => { + const prevCount = selected.metadata.members?.length ?? 0; + // Set the durable collaboration marker ONCE, when the roster first + // grows beyond its solo creator (first invite). Never re-stamped and + // never cleared on removal — see ImprovementProject.collaboratedAt. + const markFirstInvite = + members.length > prevCount && !selected.collaboratedAt ? { collaboratedAt: now } : {}; + applyProjectPatch(selected, { + metadata: { ...selected.metadata, members }, + ...markFirstInvite, + }); + }} onRequestSignoff={() => applyProjectPatch(selected, { signoff: { @@ -172,7 +203,8 @@ const ProjectsTabView: React.FC = ({ ...(selected.signoff ?? {}), requestedAt: selected.signoff?.requestedAt ?? Date.now(), approvedAt: Date.now(), - approvedBy: activeHub.processOwner ?? { displayName: 'Process owner' }, + // Acting user, not the process owner (sign-off decoupled, IM-7 Task 3). + approvedBy: actingApprover(selected, currentUserId), }, }) } diff --git a/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx b/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx index 563d663ca..369e330c4 100644 --- a/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx +++ b/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx @@ -82,7 +82,8 @@ describe('ProjectsTabView', () => { const hub: ProcessHub = { ...baseHub, processOwner: { displayName: 'Pat Process', upn: 'pat@example.com' }, - improvementProject: makeIP(), + // collaboratedAt makes the sign-off section visible (collaboration affordance). + improvementProject: makeIP({ collaboratedAt: 1_700_000_000_000 }), }; render( @@ -107,6 +108,55 @@ describe('ProjectsTabView', () => { ); }); + it('approves with the acting user (not the process owner) and works with no process owner set', () => { + const onProjectPatch = vi.fn(); + const hub: ProcessHub = { + ...baseHub, + // No processOwner on the hub — sign-off must still be approvable. + improvementProject: makeIP({ + collaboratedAt: 1_700_000_000_000, + signoff: { requestedAt: 1_700_000_100_000 }, + metadata: { + title: 'Approve without owner', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@contoso.com', + displayName: 'Analyst Lead', + role: 'lead', + invitedAt: 0, + }, + ], + }, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + + // approvedBy is the acting user (resolved from membership), never the process owner. + expect(onProjectPatch).toHaveBeenCalledWith( + 'ip-1', + expect.objectContaining({ + signoff: expect.objectContaining({ + approvedAt: expect.any(Number), + approvedBy: expect.objectContaining({ displayName: 'Analyst Lead' }), + }), + }) + ); + }); + it('threads currentUserId into IPDetailPage — charter team section is visible', () => { // Use 'draft' status so charter is the default active stage (deriveStageState returns // charter: 'current' for drafts, approach: 'current' for active which shifts the default). @@ -176,4 +226,141 @@ describe('ProjectsTabView', () => { ]) ); }); + + it('sets collaboratedAt once on the first invite (roster grows from solo)', () => { + const onProjectPatch = vi.fn(); + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ status: 'draft', metadata: { title: 'First invite' } }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'lead@contoso.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'lead' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + expect(onProjectPatch).toHaveBeenCalledWith( + 'ip-1', + expect.objectContaining({ collaboratedAt: expect.any(Number) }) + ); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toEqual( + expect.any(Number) + ); + }); + + it('does not re-stamp collaboratedAt on a second invite (idempotent)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Already collaborative', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@contoso.com', + displayName: 'Analyst Lead', + role: 'lead', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'member@contoso.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'member' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + // The second invite patches members but must NOT carry a collaboratedAt key + // (the marker is set-once; re-stamping would move the durable timestamp). + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); + + it('does not clear collaboratedAt when a member is removed (durable marker)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Removal keeps marker', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@contoso.com', + displayName: 'Analyst Lead', + role: 'lead', + invitedAt: 0, + }, + { + id: 'pm-member', + createdAt: 0, + deletedAt: null, + userId: 'member@contoso.com', + displayName: 'Member', + role: 'member', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Member' })); + + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); }); diff --git a/apps/azure/src/pages/Editor.tsx b/apps/azure/src/pages/Editor.tsx index e46f8b690..6aa3f001e 100644 --- a/apps/azure/src/pages/Editor.tsx +++ b/apps/azure/src/pages/Editor.tsx @@ -712,7 +712,9 @@ export const Editor: React.FC = ({ const projectsClosureInputs = projectsControlHandoff ? { controlPlanDocumented: false, - trainingDelivered: Boolean(projectsControlHandoff.signoff?.approvedBy), + // Re-pointed from the deleted handoff.signoff to the handoff lifecycle + // (IM-7 §11 #6): "operational" is the fully-handed-off milestone. + trainingDelivered: projectsControlHandoff.status === 'operational', cadenceAssigned: Boolean(projectsControlRecord?.cadence), processOwnerAcknowledged: projectsControlHandoff.status !== 'pending', trainingRef: projectsControlHandoff.referenceUri, diff --git a/apps/azure/src/persistence/__tests__/applyAction.control.test.ts b/apps/azure/src/persistence/__tests__/applyAction.control.test.ts index d82bb8b8b..dfdbc4c1c 100644 --- a/apps/azure/src/persistence/__tests__/applyAction.control.test.ts +++ b/apps/azure/src/persistence/__tests__/applyAction.control.test.ts @@ -189,7 +189,7 @@ function makeHandoff( } describe('applyAction (Azure) — control handoffs', () => { - it('creates, updates, acknowledges, signs off, marks operational, and archives handoffs', async () => { + it('creates, updates, acknowledges, marks operational, and archives handoffs', async () => { await db.processHubs.put(makeHub('hub-handoff')); await applyAction({ @@ -209,11 +209,6 @@ describe('applyAction (Azure) — control handoffs', () => { acknowledgedBy: { displayName: 'Process owner' }, notes: 'Accepted', }); - await applyAction({ - kind: 'CONTROL_HANDOFF_SIGNOFF', - handoffId: 'handoff-1', - signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, - }); await applyAction({ kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', handoffId: 'handoff-1', @@ -232,7 +227,6 @@ describe('applyAction (Azure) — control handoffs', () => { acknowledgedBy: { displayName: 'Process owner' }, notes: 'Accepted', }, - signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, }); expect(stored?.deletedAt).toEqual(expect.any(Number)); }); diff --git a/apps/azure/src/persistence/applyAction.ts b/apps/azure/src/persistence/applyAction.ts index 2a0397331..fd31f72d4 100644 --- a/apps/azure/src/persistence/applyAction.ts +++ b/apps/azure/src/persistence/applyAction.ts @@ -449,18 +449,6 @@ export async function applyAction(action: HubAction): Promise { return; } - case 'CONTROL_HANDOFF_SIGNOFF': { - const existing = await db.controlHandoffs.get(action.handoffId); - if (!existing) return; - const operationalAt = existing.operationalAt ?? action.signoff.approvedAt ?? Date.now(); - await db.controlHandoffs.update(action.handoffId, { - status: 'operational', - operationalAt, - signoff: { ...(existing.signoff ?? {}), ...action.signoff }, - }); - return; - } - // ------------------------------------------------------------------------- // Session-only — Azure has no dedicated Dexie table today; F3 normalizes. // ------------------------------------------------------------------------- diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 1dd6cfacb..16c91d561 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -913,7 +913,9 @@ function AppMain() { const projectsClosureInputs = projectsControlHandoff ? { controlPlanDocumented: false, - trainingDelivered: Boolean(projectsControlHandoff.signoff?.approvedBy), + // Re-pointed from the deleted handoff.signoff to the handoff lifecycle + // (IM-7 §11 #6): "operational" is the fully-handed-off milestone. + trainingDelivered: projectsControlHandoff.status === 'operational', cadenceAssigned: Boolean(projectsControlRecord?.cadence), processOwnerAcknowledged: projectsControlHandoff.status !== 'pending', trainingRef: projectsControlHandoff.referenceUri, @@ -1307,11 +1309,6 @@ function AppMain() { ); }); }} - onNudgeSignoff={projectId => { - console.info( - `[projects] Nudge signoff for ${projectId} — EngagementEvent webhook boundary` - ); - }} onStartNewProject={panels.showCharter} /> ) : panels.activeView === 'improvement' ? ( diff --git a/apps/pwa/src/components/ProjectsTabView.tsx b/apps/pwa/src/components/ProjectsTabView.tsx index a97c3a6e8..c74b2eaff 100644 --- a/apps/pwa/src/components/ProjectsTabView.tsx +++ b/apps/pwa/src/components/ProjectsTabView.tsx @@ -28,7 +28,7 @@ interface ProjectsTabViewProps { projectId: ImprovementProject['id'], patch: Extract['patch'] ) => void; - onNudgeSignoff?: (projectId: ImprovementProject['id']) => void; + // PWA never exposes sign-off (IM-7 §9.2): no onNudgeSignoff / onApproveSignoff. onStartNewProject?: () => void; } @@ -86,7 +86,6 @@ const ProjectsTabView: React.FC = ({ onOpenLegacyControl, onNudgeProcessOwner, onProjectPatch, - onNudgeSignoff, onStartNewProject, }) => { const [now] = React.useState(() => Date.now()); @@ -153,30 +152,24 @@ const ProjectsTabView: React.FC = ({ actions={approachInputs?.actions} now={now} currentUserId={PWA_USER_ID} - onMembersChange={(members: ProjectMember[]) => - applyProjectPatch(selected, { metadata: { ...selected.metadata, members } }) - } - onRequestSignoff={() => - applyProjectPatch(selected, { - signoff: { - ...(selected.signoff ?? {}), - requestedAt: Date.now(), - approvedAt: undefined, - approvedBy: undefined, - }, - }) - } - onNudgeSignoff={() => onNudgeSignoff?.(selected.id)} - onApproveSignoff={() => + onMembersChange={(members: ProjectMember[]) => { + const prevCount = selected.metadata.members?.length ?? 0; + // Set the durable collaboration marker ONCE, when the roster first + // grows beyond its solo creator (first invite). Never re-stamped and + // never cleared on removal — see ImprovementProject.collaboratedAt. + const markFirstInvite = + members.length > prevCount && !selected.collaboratedAt ? { collaboratedAt: now } : {}; applyProjectPatch(selected, { - signoff: { - ...(selected.signoff ?? {}), - requestedAt: selected.signoff?.requestedAt ?? Date.now(), - approvedAt: Date.now(), - approvedBy: activeHub.processOwner ?? { displayName: 'Process owner' }, - }, - }) - } + metadata: { ...selected.metadata, members }, + ...markFirstInvite, + }); + }} + // PWA never exposes sign-off (IM-7 §9.2): it is a single-user, Mode-1 + // solo surface. No onRequestSignoff / onNudgeSignoff / onApproveSignoff + // is wired here. The team rail gates the sign-off section on BOTH + // isCollaborative(ip) AND at least one sign-off callback being present, + // so the section is fully absent — even after collaboratedAt is stamped + // by a local invite — because no callbacks are wired in the PWA. /> ); } diff --git a/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx b/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx index 0355d4443..80072ccaf 100644 --- a/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx +++ b/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx @@ -73,8 +73,7 @@ describe('ProjectsTabView', () => { expect(onStartNewProject).toHaveBeenCalledTimes(1); }); - it('updates the project store and emits a patch from detail signoff actions', () => { - const onProjectPatch = vi.fn(); + it('never exposes a sign-off section — PWA is a Mode-1 solo surface (IM-7 §9.2)', () => { const hub: ProcessHub = { ...baseHub, processOwner: { displayName: 'Pat Process', upn: 'pat@example.com' }, @@ -86,21 +85,54 @@ describe('ProjectsTabView', () => { activeHub={hub} selectedProjectId="ip-1" onSelectProject={() => {}} - onProjectPatch={onProjectPatch} + onProjectPatch={() => {}} /> ); - fireEvent.click(screen.getByRole('button', { name: /request approval/i })); + // Solo project has no collaboratedAt marker → the team-rail sign-off + // section self-hides, and PWA wires no sign-off callbacks at all. + expect(screen.queryByRole('button', { name: /request approval/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Signoff' })).not.toBeInTheDocument(); + }); - expect(onProjectPatch).toHaveBeenCalledWith( - 'ip-1', - expect.objectContaining({ - signoff: expect.objectContaining({ requestedAt: expect.any(Number) }), - }) - ); - expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.signoff).toEqual( - expect.objectContaining({ requestedAt: expect.any(Number) }) + it('sign-off section stays absent even after collaboratedAt is stamped (no PWA callbacks wired)', () => { + // After a local invite the PWA stamps collaboratedAt, but PWA never wires + // onRequestSignoff / onNudgeSignoff / onApproveSignoff (§9.2 solo contract). + // The team-rail must NOT render the Signoff section in this state — the + // gating is on callbacks present, not just collaboratedAt. + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + collaboratedAt: 1_700_000_000_000, + metadata: { + title: 'Post-invite PWA project', + members: [ + { + id: 'pm-1', + createdAt: 0, + deletedAt: null, + userId: 'member@example.com', + displayName: 'Member', + role: 'member', + invitedAt: 0, + }, + ], + }, + }), + }; + + render( + {}} + onProjectPatch={() => {}} + // Intentionally no sign-off callbacks — PWA never passes them. + /> ); + + expect(screen.queryByRole('button', { name: /request approval/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Signoff' })).not.toBeInTheDocument(); }); it('threads PWA_USER_ID into IPDetailPage — charter team section is visible', () => { @@ -170,4 +202,136 @@ describe('ProjectsTabView', () => { ]) ); }); + + it('sets collaboratedAt once on the first invite (roster grows from solo)', () => { + const onProjectPatch = vi.fn(); + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ status: 'draft', metadata: { title: 'First invite' } }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'lead@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'lead' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + expect(onProjectPatch).toHaveBeenCalledWith( + 'ip-1', + expect.objectContaining({ collaboratedAt: expect.any(Number) }) + ); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toEqual( + expect.any(Number) + ); + }); + + it('does not re-stamp collaboratedAt on a second invite (idempotent)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Already collaborative', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@local', + displayName: 'Analyst', + role: 'lead', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'member@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'member' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); + + it('does not clear collaboratedAt when a member is removed (durable marker)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Removal keeps marker', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@local', + displayName: 'Analyst', + role: 'lead', + invitedAt: 0, + }, + { + id: 'pm-member', + createdAt: 0, + deletedAt: null, + userId: 'member@example.com', + displayName: 'Member', + role: 'member', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Member' })); + + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); }); diff --git a/apps/pwa/src/persistence/__tests__/applyAction.control.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.control.test.ts index dd187dfdb..64a369175 100644 --- a/apps/pwa/src/persistence/__tests__/applyAction.control.test.ts +++ b/apps/pwa/src/persistence/__tests__/applyAction.control.test.ts @@ -209,7 +209,7 @@ function makeHandoff( } describe('applyAction — control handoffs', () => { - it('creates, updates, acknowledges, signs off, marks operational, and archives handoffs', async () => { + it('creates, updates, acknowledges, marks operational, and archives handoffs', async () => { await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-handoff') }); await applyAction(db, { @@ -229,11 +229,6 @@ describe('applyAction — control handoffs', () => { acknowledgedBy: { displayName: 'Process owner' }, notes: 'Accepted', }); - await applyAction(db, { - kind: 'CONTROL_HANDOFF_SIGNOFF', - handoffId: 'handoff-1', - signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, - }); await applyAction(db, { kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', handoffId: 'handoff-1', @@ -252,7 +247,6 @@ describe('applyAction — control handoffs', () => { acknowledgedBy: { displayName: 'Process owner' }, notes: 'Accepted', }, - signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } }, }); expect(stored?.deletedAt).toEqual(expect.any(Number)); }); diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts index 5a78761d2..0ca63314c 100644 --- a/apps/pwa/src/persistence/applyAction.ts +++ b/apps/pwa/src/persistence/applyAction.ts @@ -369,18 +369,6 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise **Mode-1** — single-user solo investigation in the free PWA. No invite, no collaborators, no sign-off. Collaboration affordances and sign-off are Azure-only features gated by the `collaboratedAt` invite marker; they are hidden when the Project has never had a second member. + +See also: [User Flows index](index.md), [PWA Education Flow](pwa-education.md), [First-Time Explorer Flow](first-time.md). + +--- + +## Persona + +| Attribute | Detail | +| --------------- | ----------------------------------------------------------------------------- | +| **Role** | Improvement specialist — solo analyst (GB / BB / CI engineer) | +| **Goal** | Run a structured investigation on their own data; produce a shareable Report | +| **Knowledge** | Familiar with variation analysis; may be new to VariScout | +| **Entry point** | Direct to the PWA at `/app`; no sign-in required | +| **Constraints** | Session-only storage (no cloud persist); no team collaboration; no CoScout AI | + +### What they are thinking + +- "I have a dataset and a problem — I need to figure out what's driving variation." +- "I don't need a team right now. I just need to work through this systematically." +- "Can I export this so I can share it later?" + +--- + +## Journey Flow + +```mermaid +flowchart TD + A[Navigate to /app] --> B[Home — project list] + B --> C[Create Project
Title + Issue Statement] + C --> D[Process tab
Sketch process map, set scope] + D --> E[Explore tab
Paste data, run Four Lenses] + E --> F{Pattern found?} + F -->|Yes| G[Create Finding on canvas] + G --> H[Analyze tab — Investigation Wall
Group Findings into Hypotheses] + F -->|Not yet| E + H --> I[Add Measurement Plan rows
for outstanding evidence gaps] + I --> J[Improve tab
Define improvement actions + owners] + J --> K[Advance to Control
cadence + drift watch] + K --> L[Report tab
Compile findings, actions, Control status] + L --> M[Export / save Report
as .vrs or PDF] + M --> N[Session ends — data cleared
on next session start] +``` + +### Sequence across the 7-tab nav + +```mermaid +sequenceDiagram + actor Solo as Solo Analyst + participant Home + participant Project + participant Process + participant Explore + participant Analyze + participant Improve + participant Report + + Solo->>Home: Open PWA (no sign-in) + Solo->>Project: Create Project — title + issue statement + Note over Project: No Charter ceremony.
Project = single-user collaboration
via invite when ready (Azure only). + Solo->>Process: Sketch process map, set scope dimensions + Solo->>Explore: Paste data; run Four Lenses + Note over Explore: Linked filtering active.
Solo finds patterns, creates Findings. + Solo->>Analyze: Group Findings into Hypotheses on the Wall + Note over Analyze: Measurement Plans capture
outstanding evidence gaps. + Solo->>Improve: Define improvement actions, owners, dates + Note over Improve: Active-IP cascade scopes
upstream tabs to this Project. + Solo->>Improve: Advance to Control (cadence + drift watch) + Solo->>Report: Compile findings, actions, Control status + Solo->>Report: Export Report (.vrs / PDF) + Note over Report: Sign-off section hidden
(PWA wires no sign-off callbacks — Mode-1). +``` + +--- + +## What is hidden in Mode-1 (solo) + +The following surfaces are collaboration affordances that only appear once the Project has a second member (i.e., after an invite has been accepted on the Azure tier, which sets the `collaboratedAt` marker): + +| Surface | Solo PWA behavior | Azure collaborative behavior | +| ------------------------ | ----------------------------------------------- | ----------------------------------------------------- | +| Sign-off section | Always hidden (PWA wires no sign-off callbacks) | Visible; optional, non-blocking; acting user approves | +| Member roster invite CTA | Not present (PWA has no invite flow) | Present; invite adds member immediately | +| Sponsor role | Not applicable | Identity + notification label; not an ACL gate | +| CoScout AI panel | Not available (PWA free tier) | Available on Azure tenant SKU | +| Cloud sync | Not available (session-only storage) | Blob Storage sync per ADR-059 | + +> **Design rationale:** sign-off is an Azure collaboration affordance. The team rail gates the sign-off section on both the `collaboratedAt` marker AND at least one sign-off callback being wired by the surface. The PWA wires no sign-off callbacks (Mode-1 solo contract), so the section is always absent — even after a local invite stamps `collaboratedAt`. Azure wires all three callbacks (`onRequestSignoff` / `onNudgeSignoff` / `onApproveSignoff`) so the section appears once a project becomes collaborative. See [ADR-082](../../07-decisions/adr-082-wedge-architecture.md) and the [IM-7 decision-log entry](../../decision-log.md). + +--- + +## Data persistence (PWA) + +The PWA does **not** persist data between sessions. Closing the browser tab loses all work. This is by design — the PWA is a free learning and solo-investigation tool, not a production environment. + +| What is available | Where | Retention | +| -------------------- | ----------------- | ----------------------------------- | +| Current analysis | In-memory (React) | Current session only | +| Active Project state | IndexedDB (local) | Until browser storage is cleared | +| Theme preference | localStorage | Persists across sessions | +| Service Worker cache | Cache API | App loads offline after first visit | + +Analysts who need durable storage, team collaboration, or CoScout AI should use the [Azure App](azure-team-collaboration.md). + +--- + +## Upgrade path to Azure + +A solo analyst hits the natural ceiling of Mode-1 when they need to: + +- **Save and share work** beyond a single session +- **Invite a team member** (Member / Sponsor) for review +- **Use CoScout AI** for hypothesis context and auto-fire signals +- **Get out-of-band sign-off** tracked and recorded in the Report + +| Trigger | What the analyst sees | +| ------------------ | ------------------------------------------------------------ | +| Hits session limit | Upgrade context: "Save and sync with Azure App" | +| Needs team review | Upgrade context: "Invite your team with Azure App" | +| Wants CoScout AI | Upgrade context: "CoScout available on the Azure tenant SKU" | + +The upgrade path is helpful, not blocking. The PWA solo flow is pedagogically complete — the analyst can produce a full Report. Collaboration is the ceiling, not the entry requirement. + +--- + +## Success signals + +A solo investigation has succeeded when: + +- **Problem is framed.** Process map sketched; scope dimensions set; issue statement written. +- **Variation located.** At least one Finding created from canvas exploration. +- **Hypotheses structured.** Findings grouped into Hypotheses on the Investigation Wall; evidence gaps logged as Measurement Plan rows. +- **Actions committed.** Each action has an owner, target date, and acceptance signal on the Improve tab. +- **Report exportable.** Findings, actions, and Control status compile into a shareable Report. + +--- + +## Related flows + +- [First-Time Explorer Flow](first-time.md) — onboarding moment for brand-new users +- [PWA Education Flow](pwa-education.md) — training-room use with sample datasets +- [Azure — First Analysis](azure-first-analysis.md) — same investigation with durable storage + CoScout +- [Azure — Team Collaboration](azure-team-collaboration.md) — Mode-2 collaborative flow (invite, sign-off, Sponsor review) diff --git a/docs/02-journeys/ia-nav-model.md b/docs/02-journeys/ia-nav-model.md index 4959ac679..9babfa5ef 100644 --- a/docs/02-journeys/ia-nav-model.md +++ b/docs/02-journeys/ia-nav-model.md @@ -48,7 +48,7 @@ The solid arrows are the workflow walk (left-to-right). The dotted arrows are th ### Project -**Purpose**: project-scoped Charter, member roster, lifecycle stage view (Charter → Approach → Control), and Project-level metadata. Sign-off gates (Charter approval, Control cadence) live here. +**Purpose**: project-scoped Charter, member roster, lifecycle stage view (Charter → Approach → Control), and Project-level metadata. Optional, non-blocking sign-off (Azure collaboration affordance; hidden solo) lives here. **Primary action**: read the Charter; advance stage (Lead-only); approve (Sponsor for Charter; Lead for hypothesis closure). ### Process @@ -73,12 +73,12 @@ The solid arrows are the workflow walk (left-to-right). The dotted arrows are th ### Report -**Purpose**: terminal compilation surface. Findings, Hypotheses, Actions, and Control status compile into a Report the Sponsor signs off and the team can share. Read-mostly for everyone except the Lead during compilation. +**Purpose**: terminal compilation surface. Findings, Hypotheses, Actions, and Control status compile into a Report the Sponsor reviews (sign-off optional/out-of-band) and the team can share. Read-mostly for everyone except the Lead during compilation. **Primary action**: review interim status during Control; sign off final Report (Sponsor). ## Active-IP cascade rules -An **active IP** is the Improvement Project the Lead has selected as their current working focus. IPs are created via Charter ceremony (see Project tab); the active IP is then selected from the Lead's portfolio. At most one IP is active at a time per persona session. When an IP is active: +An **active IP** is the Improvement Project the Lead has selected as their current working focus. Project = collaboration via invite; no Charter ceremony — the Lead creates the Project from Home and invites Members directly. The active IP is then selected from the Lead's portfolio. At most one IP is active at a time per persona session. When an IP is active: - **Project tab** filters its Charter / roster view to the IP's scope. The Project-level Charter remains accessible; the IP's working Charter sits underneath. - **Process tab** highlights the process steps the IP touches; non-IP scope dims. diff --git a/docs/02-journeys/personas/lead.md b/docs/02-journeys/personas/lead.md index dd4836fb5..d383f657e 100644 --- a/docs/02-journeys/personas/lead.md +++ b/docs/02-journeys/personas/lead.md @@ -52,12 +52,12 @@ sequenceDiagram Lead->>Analyze: Group Findings into Hypotheses on the Wall Note over Analyze: Lead defines Measurement Plans,
tags suspected contributions Analyze->>Analyze: Members contribute evidence - Lead->>Improve: Elevate work into a Project via Charter (hypotheses inherited as context if any) + Lead->>Improve: Project = collaboration via invite (create from Home, invite Members; hypotheses auto-scope to the active IP) Note over Improve: Active-IP cascade lights up
downstream tabs (Project / Process /
Explore / Analyze filtered to IP) Lead->>Improve: Define improvement actions, owners, dates Lead->>Improve: Advance to Control (cadence + drift watch) Lead->>Report: Compile findings, actions, Control status - Report->>Lead: Sponsor reviews + signs off + Report->>Lead: Sponsor reviews (out-of-band sign-off; Lead records) ``` The **active-IP cascade** is Lead-owned: when the Lead selects an Improvement Project as their active working focus, downstream tabs scope to that IP until the Lead changes it. Members and Sponsors see the cascade but cannot alter the active-IP selection. diff --git a/docs/02-journeys/personas/sponsor.md b/docs/02-journeys/personas/sponsor.md index 144f29778..fbd71333b 100644 --- a/docs/02-journeys/personas/sponsor.md +++ b/docs/02-journeys/personas/sponsor.md @@ -16,7 +16,7 @@ last-reviewed: 2026-05-27 ## Persona statement -The **Sponsor** is the executive accountable for the Project's outcome. They authorize the Charter, hold the team accountable through **Control**, and accept the final Report. They do not run the analysis, propose hypotheses, or own action items — those are Lead and Member work. The Sponsor's interaction is read-mostly + approval gates. +The **Sponsor** is the executive accountable for the Project's outcome. They review the Charter, hold the team accountable through **Control**, and accept the final Report. They do not run the analysis, propose hypotheses, or own action items — those are Lead and Member work. The Sponsor's interaction is read-mostly; sign-off and acknowledgement happen out-of-band (the Lead records the result as a note). Real-world counterparts: VP of Operations, Plant Manager, Quality Director, executive Champion. The Sponsor often has many Projects in flight; their VariScout time is bounded. @@ -24,11 +24,11 @@ A Sponsor is invited to specific Projects. They see only those on Home. They nev ## JTBD -> **When I** sponsor a Project, **I want to** approve the Charter + review **Control** cadence, **so I can** hold the team accountable for the improvement outcome. +> **When I** sponsor a Project, **I want to** review the Charter + follow the **Control** cadence, **so I can** hold the team accountable for the improvement outcome. Supporting jobs: -- When the Lead opens a Project, I want to confirm the problem is worth solving and the scope is right. +- When the Lead opens a Project, I want to read the problem statement and confirm the scope makes sense. - When the team commits to improvement actions, I want to know what success looks like + when to expect results. - When **Control** surfaces a drift signal, I want to see it without digging through the Wall. - When the Project is done, I want a Report I can share upward and to my peers. @@ -48,17 +48,17 @@ sequenceDiagram Sponsor->>Home: Open VariScout, see Projects I sponsor Sponsor->>Project: Open Project (read Charter) - Note over Project: Sponsor signs off Charter scope
(approval gate) + Note over Project: Sponsor reviews Charter scope
(out-of-band; Lead records result) Sponsor->>Explore: Read (optional engagement) Sponsor->>Analyze: Read — see Wall, evidence, Measurement Plans Sponsor->>Improve: Review proposed actions + owners - Note over Improve: Sponsor sees active-IP cascade
(read-only), approves IP scope + Note over Improve: Sponsor sees active-IP cascade
(read-only), reviews IP scope Sponsor->>Report: Read interim status during Control Note over Report: Control drift signal
surfaces to Sponsor - Sponsor->>Report: Sign off final Report (approval gate) + Sponsor->>Report: Review final Report (out-of-band sign-off; Lead records) ``` -The Sponsor reads Explore + Analyze when they want to engage with the analysis directly. Their active gestures are bounded to approval gates which happen out-of-band per wedge V1 (Lead records the signoff as a note). The Sponsor's primary touch-points are **Home** (project list), **Project** (Charter sign-off), **Improve** (action review), and **Report** (interim + final review). +The Sponsor reads Explore + Analyze when they want to engage with the analysis directly. Their active gestures are review and acknowledgement — sign-off happens out-of-band (the Lead records the result as a note). The Sponsor's primary touch-points are **Home** (project list), **Project** (Charter review), **Improve** (action review), and **Report** (interim + final review). ## Feature touch-points @@ -72,13 +72,13 @@ Supporting reference: [`flows/enterprise.md`](../flows/enterprise.md), [`flows/a A Sponsor has succeeded when: -- **Charter is approved.** Sponsor agreed the problem is worth solving and the scope is correct. +- **Charter is reviewed.** Sponsor has read the problem statement and agreed on scope (out-of-band; the Lead notes the result). - **Action plan is sanctioned.** Sponsor saw the proposed improvement actions and accepted them. - **Drift is visible.** During **Control**, the Sponsor sees signals without digging — surfaced to Home or via the Report. -- **Final Report is signed off.** Sponsor accepts the outcome and can share it upward. +- **Final Report is reviewed.** Sponsor has read the outcome; sign-off is out-of-band and the Lead records the result. Failure modes the journey is designed to prevent: - Sponsor making structural edits they shouldn't (ACL gates: Sponsor cannot author canvas, close hypotheses, compile Report, or advance stages — Lead-only per 2-tier ACL §4.1; Sponsor may contribute Findings, evidence, comments) - Sponsor missing drift signals (**Control** surfaces them to the Sponsor explicitly, not buried in the Wall) -- Sign-off untracked (sign-off is out-of-band per wedge V1; Lead records the result as a note in the relevant stage) +- Sign-off untracked (sign-off is optional, non-blocking, and out-of-band; Lead records the result as a note in the relevant stage — the sign-off section is hidden when the Project has no collaborators) diff --git a/docs/03-features/workflows/control.md b/docs/03-features/workflows/control.md index 5d61e8c4c..c694e236b 100644 --- a/docs/03-features/workflows/control.md +++ b/docs/03-features/workflows/control.md @@ -1,5 +1,5 @@ --- -title: 'Sustainment Phase' +title: 'Control Phase' purpose: design tier: living status: draft @@ -8,20 +8,20 @@ layer: L3 kind: workflow serves: - docs/02-journeys/index.md -last-reviewed: 2026-05-18 +last-reviewed: 2026-05-30 --- > **L3 feature stub** — created 2026-05-18 as part of M0 SDD migration inventory (Option A). Body to be expanded in M3 audit or on next feature edit. -# Sustainment Phase +# Control Phase ## Problem -Improvement projects fail when the team stops monitoring after the fix lands — drift returns, the change is silently rolled back, or the control surface (MES recipe, SCADA alarm, work instruction) goes stale; the third Project stage in the wedge V1 `Charter → Approach → Sustainment` model exists to keep the proof going. +Improvement projects fail when the team stops monitoring after the fix lands — drift returns, the change is silently rolled back, or the control surface (MES recipe, SCADA alarm, work instruction) goes stale; the third Project stage in the wedge V1 `Charter → Approach → Control` model exists to keep the proof going. ## Capability claim -Sustainment domain types live in `packages/core/src/sustainment.ts` (`SustainmentCadence` weekly through annual, `SustainmentVerdict` of `'holding' | 'drifting' | 'broken' | 'inconclusive'`, `SustainmentStatus`, and `ControlHandoffSurface` enumerating `'mes-recipe' | 'scada-alarm' | 'qms-procedure' | 'work-instruction'`), with Azure UI in `apps/azure/src/components/sustainment/SustainmentPanel.tsx` + editors and CoScout auto-fire on Sustainment events per ADR-080. +Control domain types live in `packages/core/src/sustainment.ts` (`SustainmentCadence` weekly through annual, `SustainmentVerdict` of `'holding' | 'drifting' | 'broken' | 'inconclusive'`, `SustainmentStatus`, and `ControlHandoffSurface` enumerating `'mes-recipe' | 'scada-alarm' | 'qms-procedure' | 'work-instruction'`), with Azure UI in `apps/azure/src/components/sustainment/SustainmentPanel.tsx` + editors and CoScout auto-fire on Control events per ADR-080. Note: code identifiers (`sustainment`, `SustainmentCadence`, etc.) are preserved as stable tokens per Task #40 rename discipline. ## Intent diagram @@ -29,7 +29,7 @@ Sustainment domain types live in `packages/core/src/sustainment.ts` (`Sustainmen flowchart LR Approach[Approach signoff
fix shipped] --> Surface[Control handoff
MES / SCADA /
QMS / Work instr.] Surface --> Cadence{Cadence} - Cadence -->|weekly| Rev[Sustainment review] + Cadence -->|weekly| Rev[Control review] Cadence -->|monthly| Rev Cadence -->|quarterly| Rev Cadence -->|annual| Rev @@ -41,7 +41,7 @@ flowchart LR Continue --> Cadence ``` -Third Project stage in the wedge V1 `Charter → Approach → Sustainment` model. `SustainmentVerdict` drives the branch (`holding | drifting | broken | inconclusive`); CoScout auto-fires on Sustainment events per ADR-080. +Third Project stage in the wedge V1 `Charter → Approach → Control` model. `SustainmentVerdict` drives the branch (`holding | drifting | broken | inconclusive`); CoScout auto-fires on Control events per ADR-080. ## Acceptance signals diff --git a/docs/decision-log.md b/docs/decision-log.md index 7e588167e..d47a5dccf 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -28,6 +28,8 @@ When in doubt: capture, don't invent. Record the decision; link to its source ar Decisions we keep relitigating. Each entry: short statement, rationale, closing artifact, date pinned. +- **2026-05-30 — IM-7: `IP.signoff` is canonical; `ControlHandoff.signoff` + `CONTROL_HANDOFF_SIGNOFF` deleted; closure gated by `collaboratedAt`, not `processOwner`.** `decision`: Spec §11 #6 graduated. `IP.signoff` (`ImprovementProjectSignoff`: `requestedAt?/approvedAt?/approvedBy?`) is the one canonical closure path; it lives on the Project lifecycle entity where it belongs. Surface B (`ControlHandoff.signoff` field on `packages/core/src/control.ts:112` + the `CONTROL_HANDOFF_SIGNOFF` action on `controlHandoffActions.ts:37-41`) had ZERO UI dispatch (`CONTROL_HANDOFF_SIGNOFF` was reachable only from tests) — it was dead scaffolding. Its two read consumers (`apps/pwa/src/App.tsx` + `apps/azure/src/pages/Editor.tsx`, both checking `Boolean(controlHandoff.signoff?.approvedBy)` to feed `trainingDelivered`/`processOwnerAcknowledged`) were re-pointed to `handoff.status === 'operational'` / `handoff.acknowledgedAt` — the existing fields that carry the real handoff lifecycle state. `collaboratedAt?: number` is the new invite marker: set once on the first member invite, never cleared, and it gates the Azure-only sign-off section UI (sign-off is hidden when `!collaboratedAt` — "hidden solo"). Sign-off is optional and non-blocking: `canApprove = pendingSignoff` (no `processOwner` requirement); the acting user is the approver, not the processOwner. `ProcessHub.processOwner` is unchanged (it has independent uses). Sponsor role unchanged: identity/notification label only — `ROLE_PERMISSIONS` keeps `member === sponsor`; no `approve-*` action added. IDB: no version bump (`collaboratedAt` is a non-indexed root field; Surface-B removal is a non-indexed field deletion). Shipped in PR IM-7 (`im-7-cluster-a-closure`). Apply-phase doc layer: `ia-nav-model.md` (sign-off gate → optional/non-blocking), `personas/sponsor.md` (de-gatekeeper), `personas/lead.md` (Charter ceremony → invite), `positioning.md` + `control.md` (Sustainment→Control renames), new `flows/pwa-solo-investigation.md` (Mode-1 solo journey), `product-overview.md` (drop "Charter ceremony" + reframe sign-off as optional/out-of-band). Related: spec `docs/superpowers/specs/2026-05-29-investigation-surface-design.md` §11 #6, [adr-082](07-decisions/adr-082-wedge-architecture.md), [[investigation-model-design]], [[investigation-surface-build]]. _Pinned 2026-05-30._ + - **2026-05-30 — IM-2: `MeasurementPlan.hypothesisId` stays required + immutable; `neededFactors[]` = dataset column names contract.** `decision`: Spec §11 open-Q #2 resolved. `hypothesisId` is kept required on `MeasurementPlan` (no "bare condition" plan without a Hypothesis); it is excluded from `MeasurementPlanPatch` via `Omit<>` (type-level enforcement). No plan-creation path exists without a Hypothesis, so the weaker "optional at creation, set later" design adds complexity for zero V1 benefit. `neededFactors: string[]` values are **dataset column names** (not display labels) — IM-3's column-overlap matcher joins on these keys. Changing to display labels would silently break the IM-3 join. Related: [measurement-plan-dcp L3 doc](03-features/workflows/measurement-plan-dcp.md), spec §7.1, ADR-085, ADR-087. _Pinned 2026-05-30._ - **2026-05-30 — IM-1 shipped (PR #249); CoScout `legacy.ts` retirement (complete ADR-068) scheduled as a Wave-1 PR, must precede IM-3.** `decision`: IM-1 (drop `Question` entity + `ProblemStatementScope` first-class) merged as an atomic cascade (core/stores/hooks/ui/apps/data/i18n + residual cleanup + 6-package test layer), preceded by Bucket-2 pre-existing-fix PR #245 for a green baseline. Its adversarial review surfaced that `packages/core/src/ai/prompts/coScout/legacy.ts` (1647 lines, the pre-ADR-068 monolith) is a **stalled deprecation**: `buildCoScoutSystemPrompt`/`buildCoScoutMessages` have ZERO production callers (live path is `assembleCoScoutPrompt` via `tools/registry.ts`), but the dead code is kept alive by backward-compat tests (`promptTemplates.test.ts`/`promptSafety.test.ts`), forcing dual-maintenance + duplicated tool defs (IM-1 paid the tax: stale Question prompt copy + a registry/legacy split that turned a 3-line tool removal into a multi-file reconciliation). **Decision:** complete the ADR-068 migration as a dedicated Wave-1 PR (disjoint `coScout/` subtree — no conflict with IM-2/5/7/0b-2): relocate the still-live utils (`formatKnowledgeContext` ×7 callers; audit `buildCoScoutInput`/`buildCoScoutTools`) into the modular tree, move any genuine prompt-safety contract coverage onto `assembleCoScoutPrompt`, then delete the dead monolith + its backward-compat tests. **Must land before IM-3** (auto-link is CoScout-adjacent). Why: a stalled deprecation (dead code pinned by its own tests + a duplicated source of truth) costs more than completing the migration, and doing it before the CoScout-touching wave PRs avoids re-paying the tax. Build-state + tracked deferrals: [[investigation-surface-build]]; IM-1 follow-ups in [`ephemeral/investigations.md`](ephemeral/investigations.md). Related: [adr-068](07-decisions/adr-068-coscout-cognitive-redesign.md), [[investigation-model-design]]. _Pinned 2026-05-30._ diff --git a/packages/core/src/__tests__/control.test.ts b/packages/core/src/__tests__/control.test.ts index caf995617..3eab09779 100644 --- a/packages/core/src/__tests__/control.test.ts +++ b/packages/core/src/__tests__/control.test.ts @@ -60,7 +60,7 @@ describe('nextDueFromCadence', () => { }); describe('ControlHandoff V1 lifecycle shape', () => { - it('supports pending, acknowledged, and operational lifecycle state plus signoff metadata', () => { + it('supports pending, acknowledged, and operational lifecycle state', () => { const states: ControlHandoffStatus[] = ['pending', 'acknowledged', 'operational']; const handoff: ControlHandoff = { id: 'handoff-1', @@ -81,18 +81,13 @@ describe('ControlHandoff V1 lifecycle shape', () => { }, escalationPath: 'Escalate misses to the production manager.', reactionPlan: 'Restore standard work and open a focused investigation if drift repeats.', - signoff: { - requestedAt: 1_746_353_000_000, - approvedAt: 1_746_353_100_000, - approvedBy: { displayName: 'Sponsor' }, - }, createdAt: 1_746_352_800_000, deletedAt: null, }; expect(states).toHaveLength(3); expect(handoff.status).toBe('acknowledged'); - expect(handoff.signoff?.approvedBy?.displayName).toBe('Sponsor'); + expect(handoff.acknowledgedAt).toBe(1_746_352_900_000); }); }); diff --git a/packages/core/src/actions/__tests__/controlActions.test.ts b/packages/core/src/actions/__tests__/controlActions.test.ts index b39911651..241c2ec11 100644 --- a/packages/core/src/actions/__tests__/controlActions.test.ts +++ b/packages/core/src/actions/__tests__/controlActions.test.ts @@ -101,11 +101,6 @@ describe('ControlAction', () => { acknowledgedBy: { displayName: 'Owner' }, }, { kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL', handoffId: 'handoff-1' }, - { - kind: 'CONTROL_HANDOFF_SIGNOFF', - handoffId: 'handoff-1', - signoff: { approvedAt: 2, approvedBy: { displayName: 'Sponsor' } }, - }, ]; expect(actions.map(action => action.kind)).toEqual([ @@ -114,7 +109,6 @@ describe('ControlAction', () => { 'CONTROL_HANDOFF_ARCHIVE', 'CONTROL_HANDOFF_ACKNOWLEDGE', 'CONTROL_HANDOFF_MARK_OPERATIONAL', - 'CONTROL_HANDOFF_SIGNOFF', ]); }); }); diff --git a/packages/core/src/actions/__tests__/exhaustiveness.test.ts b/packages/core/src/actions/__tests__/exhaustiveness.test.ts index 4265441e0..7e84b1c7d 100644 --- a/packages/core/src/actions/__tests__/exhaustiveness.test.ts +++ b/packages/core/src/actions/__tests__/exhaustiveness.test.ts @@ -138,8 +138,6 @@ function _exhaustive(action: HubAction): void { return; case 'CONTROL_HANDOFF_MARK_OPERATIONAL': return; - case 'CONTROL_HANDOFF_SIGNOFF': - return; default: return assertNever(action); } diff --git a/packages/core/src/actions/controlHandoffActions.ts b/packages/core/src/actions/controlHandoffActions.ts index 66588c81c..f4b5a3cf4 100644 --- a/packages/core/src/actions/controlHandoffActions.ts +++ b/packages/core/src/actions/controlHandoffActions.ts @@ -1,6 +1,5 @@ import type { ProcessHub, ProcessParticipantRef } from '../processHub'; import type { ControlHandoff } from '../control'; -import type { ImprovementProjectSignoff } from '../improvementProject'; export type ControlHandoffAction = | { @@ -14,7 +13,7 @@ export type ControlHandoffAction = patch: Partial< Omit< ControlHandoff, - 'id' | 'createdAt' | 'hubId' | 'investigationId' | 'updatedAt' | 'deletedAt' | 'signoff' + 'id' | 'createdAt' | 'hubId' | 'investigationId' | 'updatedAt' | 'deletedAt' > >; } @@ -33,9 +32,4 @@ export type ControlHandoffAction = kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL'; handoffId: ControlHandoff['id']; operationalAt?: number; - } - | { - kind: 'CONTROL_HANDOFF_SIGNOFF'; - handoffId: ControlHandoff['id']; - signoff: ImprovementProjectSignoff; }; diff --git a/packages/core/src/control.ts b/packages/core/src/control.ts index 66b800125..7682b78b2 100644 --- a/packages/core/src/control.ts +++ b/packages/core/src/control.ts @@ -8,7 +8,7 @@ import type { ProcessParticipantRef, } from './processHub'; import type { EvidenceSnapshot } from './evidenceSources'; -import type { ImprovementProjectGoal, ImprovementProjectSignoff } from './improvementProject'; +import type { ImprovementProjectGoal } from './improvementProject'; export type ControlCadence = | 'weekly' @@ -109,7 +109,6 @@ export interface ControlHandoff extends EntityBase { }; escalationPath?: string; reactionPlan?: string; - signoff?: ImprovementProjectSignoff; } export interface ControlMetadataProjection { diff --git a/packages/core/src/improvementProject/__tests__/predicates.test.ts b/packages/core/src/improvementProject/__tests__/predicates.test.ts new file mode 100644 index 000000000..5b1970747 --- /dev/null +++ b/packages/core/src/improvementProject/__tests__/predicates.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import type { ImprovementProject } from '../types'; +import { isCollaborative } from '../predicates'; + +function makeIP(overrides: Partial = {}): ImprovementProject { + return { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + status: 'active', + metadata: { title: 'Cpk lift' }, + goal: { outcomeGoals: [] }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + ...overrides, + }; +} + +describe('isCollaborative', () => { + it('is false for a solo project with no collaboratedAt marker', () => { + expect(isCollaborative(makeIP())).toBe(false); + }); + + it('is true once collaboratedAt is set (first invite happened)', () => { + expect(isCollaborative(makeIP({ collaboratedAt: 1_700_000_000_000 }))).toBe(true); + }); + + it('stays true even when the roster is back to a single member (marker is durable)', () => { + const ip = makeIP({ + collaboratedAt: 1_700_000_000_000, + metadata: { + title: 'Cpk lift', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'lead@example.com', + displayName: 'Lead', + role: 'lead', + invitedAt: 0, + }, + ], + }, + }); + expect(isCollaborative(ip)).toBe(true); + }); +}); diff --git a/packages/core/src/improvementProject/index.ts b/packages/core/src/improvementProject/index.ts index fb963aed5..9df8d0065 100644 --- a/packages/core/src/improvementProject/index.ts +++ b/packages/core/src/improvementProject/index.ts @@ -19,3 +19,5 @@ export type { DriftableSnapshot, DriftableCurrent } from './snapshot'; export { createNewIP } from './factories'; export type { CreateNewIPInput } from './factories'; + +export { isCollaborative } from './predicates'; diff --git a/packages/core/src/improvementProject/predicates.ts b/packages/core/src/improvementProject/predicates.ts new file mode 100644 index 000000000..640d74109 --- /dev/null +++ b/packages/core/src/improvementProject/predicates.ts @@ -0,0 +1,15 @@ +import type { ImprovementProject } from './types'; + +/** + * True once a project has ever been collaborated on — i.e. its durable + * `collaboratedAt` marker has been set by the first invite. The marker is + * never cleared (removing members does not flip this back to false), so this + * is distinct from a reversible `members.length > 1` check. + * + * Gates the Azure-only collaboration affordances (the optional, non-blocking + * sign-off section). A solo PWA investigation never sets the marker, so it + * stays in Mode-1 solo and the sign-off section stays hidden. (IM-7 §11 #6.) + */ +export function isCollaborative(ip: ImprovementProject): boolean { + return Boolean(ip.collaboratedAt); +} diff --git a/packages/core/src/improvementProject/types.ts b/packages/core/src/improvementProject/types.ts index 67839475e..cf87506b9 100644 --- a/packages/core/src/improvementProject/types.ts +++ b/packages/core/src/improvementProject/types.ts @@ -140,6 +140,14 @@ export interface ImprovementProject extends EntityBase { }; updatedAt: number; signoff?: ImprovementProjectSignoff; + /** Durable collaboration marker (Unix ms). Set ONCE when the project roster + * first grows beyond its solo creator (first invite), and NEVER cleared on + * member removal. Gates the Azure-only collaboration affordances (the + * optional, non-blocking sign-off section) via `isCollaborative(ip)`. A + * solo PWA investigation never sets it — the project stays in Mode-1 solo. + * Distinct from `metadata.members.length > 1`, which is derived + reversible; + * this marker records that collaboration *happened*. (IM-7 §11 #6.) */ + collaboratedAt?: number; /** Optional analyst-authored lessons-learned narrative. Authored in * Sections mode (Control or Handoff stages typically); surfaces in * the Report Overview "What we standardized + learned" section. */ diff --git a/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx b/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx index f9ecc0a18..9c1a12065 100644 --- a/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx +++ b/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import type { ActionItem, ImprovementIdea } from '@variscout/core/findings'; import type { ControlHandoff, ProcessHub, ControlRecord } from '@variscout/core'; import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { isCollaborative } from '@variscout/core/improvementProject'; import type { ProjectMember, ProjectRole } from '@variscout/core/projectMembership'; import IPDetailAvatar from './IPDetailAvatar'; import { deriveIPActivityEvents, type IPActivityEvent } from './activityEvents'; @@ -88,7 +89,9 @@ function ActivityList({ events }: { events: IPActivityEvent[] }) { const IPDetailTeamRail: React.FC = ({ ip, raciOverrides = {}, - activeHub, + // `activeHub` stays on the props contract (callers still pass it) but the rail + // no longer reads `processOwner`: sign-off is decoupled from the process owner + // (IM-7 Task 3). Intentionally not destructured here. ideas = [], actions = [], controlRecord, @@ -115,9 +118,19 @@ const IPDetailTeamRail: React.FC = ({ [actions, controlHandoff, effectiveNow, ideas, ip, controlRecord] ); const recentEvents = events.slice(0, 5); - const approver = activeHub?.processOwner; const pendingSignoff = Boolean(ip.signoff?.requestedAt && !ip.signoff.approvedAt); - const canApprove = pendingSignoff && Boolean(approver); + // Sign-off is decoupled from the process owner and never blocks closure: + // any acting reviewer may approve while a request is pending. The approver + // identity is captured at the dispatch site (the acting user), not here. + const canApprove = pendingSignoff; + // Sign-off section is shown only when the project is collaborative (has a + // collaboratedAt marker) AND the rendering surface has wired at least one + // sign-off callback. The PWA wires none (§9.2 solo surface contract), so this + // evaluates to false even after an invite stamps collaboratedAt — the section + // is fully absent, not disabled. Azure wires all three callbacks so it always + // shows the section once the project is collaborative. + const collaborative = + isCollaborative(ip) && (!!onRequestSignoff || !!onApproveSignoff || !!onNudgeSignoff); return (