Implementation Spec — Engagements (Golden Thread Container)
Doc type: Implementation plan (Foundation → Stretch goals)
Status: Draft
Scope: Engagements / Projects as a first-class “Golden Thread” container that groups Assignments, Sessions, Submissions (K/O/E), Evidence events, review/sign-off, and (future) hashing/ratification.
Non-goals: Project management, document repository, HR/performance management.
Tier-1 alignment: This spec MUST not violate
docs/architecture/FOUNDATION.mdinvariants:
- WORM/append-only applies to the Execution Ledger (submissions + ledger events), not all scaffolding.
- TxManager + RLS are the enforcement boundary.
- “Edits” to execution records are Amend/Void patterns, not UPDATE/DELETE.
0. Definitions (simple, stable)
- Programme: reusable template/curriculum/path that issues tasks (Assignments) to users.
- Engagement: instance of real-world work (a client/job/thread) that groups tasks and durable outcomes into one timeline (“Golden Thread”).
Rule: Engagements are not a replacement for Programmes. Engagements MAY be created from a Programme, but are distinct from Programme definition objects.
1. Why Engagements exist (first-class justification)
Even though K/O/E outcomes are already WORM in submissions, you still need a first-class container that:
- Creates a stable job identity (client/site/project thread) independent of cohorts/run labels.
- Groups many tasks (issued from Programmes + ad-hoc) under one umbrella.
- Supports glass-box sharing by scoping “what the client can see” to a single Engagement.
- Provides a natural boundary for milestone proofs (future hashing + ratification) and “state at time” attestation.
- Avoids “run_label as a product feature” (run_label is a reporting tag; Engagement is a domain object with lifecycle, permissions, and events).
2. Data classification (WORM vs scaffolding)
2.1 Execution Ledger (WORM/append-only)
These remain the durable source of truth:
submissions(+submission_items, score versions, approvals, evidence metadata events, etc.)- Ledger “event” tables (append-only) that represent amendments/voids/verifications/ratifications.
2.2 Operational scaffolding (mutable by design)
Engagement “current state” can be mutable:
engagementsrow may be updated for status/title/metadata- BUT all meaningful changes MUST also be recorded as append-only engagement events.
Pattern (required): Mutable “current state” + append-only “event history”.
3. Foundation (MVP) — Minimal tables, maximum leverage
3.1 Schema (required)
A) engagements (scaffolding table; mutable)
Columns (minimum)
id UUID PKtenant_id UUID NOT NULLorg_unit_id UUID NOT NULLtitle TEXT NOT NULLstatus TEXT NOT NULL(draft|active|paused|closed|archived)created_by_user_id UUID NOT NULLcreated_at TIMESTAMPTZ NOT NULLupdated_at TIMESTAMPTZ NOT NULL
Optional (but recommended now)
client_ref TEXT NULL(customer’s internal job ref, not “client identity” in the platform)description TEXT NULLsource_program_id UUID NULL(FK toprograms.id)source_program_version_checksum TEXT NULL(only if you don’t version programmes; store the checksum or snapshot marker to explain “what template was used”)metadata JSONB NOT NULL DEFAULT '{}'::jsonb(small, non-PII job metadata)
RLS
- Must follow standard tenant+org unit policies (same pattern as other siloed tables).
B) engagement_events (append-only)
Purpose: defensible timeline of Engagement lifecycle and actions.
Columns (minimum)
id UUID PKtenant_id UUID NOT NULLorg_unit_id UUID NOT NULLengagement_id UUID NOT NULL REFERENCES engagements(id)event_type TEXT NOT NULLpayload JSONB NOT NULL DEFAULT '{}'::jsonbactor_user_id UUID NOT NULLcreated_at TIMESTAMPTZ NOT NULL
Rules
- INSERT-only.
- No UPDATE/DELETE.
- Used for audit timelines and “what changed, who changed it, when, why”.
Event types (minimum set)
engagement.createdengagement.renamedengagement.status_changedengagement.metadata_updated(careful: keep payload small)engagement.program_applied(when created-from / applied template)
C) Linkage fields (required)
Add nullable FKs:
assignments.engagement_id UUID NULL REFERENCES engagements(id)submissions.engagement_id UUID NULL REFERENCES engagements(id)
Why on submissions too?
- Submissions are durable and used by reporting.
- Assignments are scaffolding; they can be archived or reissued.
- Stamping engagement_id onto submissions makes engagement-level reporting and portal scopes stable.
Session linkage (optional for MVP)
delivery_sessions.engagement_idMAY be omitted if derivable from assignment.- If included, treat it as scaffolding (mutable/retention-bound).
3.2 Services + queries (required)
Create a dedicated domain service:
EngagementsService(new module boundary)
sqlc queries
- Create engagement
- Get engagement by id (RLS-scoped)
- List engagements (filters: status, created_at range)
- Update engagement scaffolding fields (title/status/metadata)
- Insert engagement_event
- Read engagement timeline summary (joins into submissions/assignments)
3.3 API surface (required)
Engagement CRUD
POST /api/v1/engagementsGET /api/v1/engagementsGET /api/v1/engagements/{engagementId}PATCH /api/v1/engagements/{engagementId}(limited fields)GET /api/v1/engagements/{engagementId}/timeline
Timeline output (MVP)
- Engagement events (from
engagement_events) - Submission list (from
submissionsfiltered by engagement_id), with:- submission_id
- assignment_id
- evaluation_version_id
- kind (K/O/E)
- completed_at
- approval/review status if present
- minimal “headline” fields for UI
3.4 Capability model (required)
Add first-class capabilities (names are canonical-ish; align final names to your capability taxonomy):
engagements.readengagements.createengagements.update(rename/status/metadata)engagements.close(explicit close action if you want tighter policy)engagements.timeline.viewengagements.assignments.manage(adding tasks to engagement)
Rule: No route may check roles directly; only capabilities.
3.5 Lifecycle rules (required)
- Engagements have a status. Assignments can be issued only when status allows (policy below).
- All status changes MUST create an
engagement.status_changedevent. - Renames MUST create an
engagement.renamedevent.
Policy (MVP)
- draft: no assignments unless you explicitly allow
- active: assignments allowed
- paused: no new assignments; execution of existing assignments MAY be allowed (decide later)
- closed: no new assignments; portal read-only (future)
- archived: hidden by default
3.6 Programme relationship (MVP-compatible)
Goal: Minimal churn with your existing programme worker/outbox.
Two supported patterns:
Pattern A (recommended): “Apply Programme to Engagement”
Add endpoint:
POST /api/v1/engagements/\{id\}/apply-programme
Behavior:
- Validate capability
engagements.assignments.manage. - Record
engagement.program_appliedevent with program_id + requirement count. - Use existing orchestration to issue assignments, but stamp engagement_id on each created assignment.
Implementation detail:
- Extend
AssignmentsService.CreateAssignment(...)to accept an optionalengagement_id. - Extend Programme orchestrator/worker path to pass engagement_id through when present.
Pattern B: “Create Engagement from Programme”
POST /api/v1/engagements/from-programmeCreates engagement + applies programme in one shot.
Rule: Regardless of pattern, programme requirements remain defined by Programme tables. Engagement is the instance container.
3.7 Copy engagement_id into submissions (required)
On submit (ResultsService.SubmitSession / submission creation):
- Derive engagement_id from the assignment (or session) and set it on the submission.
- This preserves “golden thread” even if assignment lifecycle changes later.
3.8 Tests (required)
Add integration tests that prove:
- Engagement is RLS-scoped by tenant+org.
- Engagement events are append-only (no update paths).
- Assignments created from programme contain
engagement_id. - Submission created from assignment contains
engagement_id. - Timeline endpoint returns both events and submissions.
4. First-class UX/Concept rules (so it doesn’t become a “tag”)
Even without a frontend, the backend must treat Engagements as first-class by ensuring:
- Dedicated table + service + endpoints
- Explicit capabilities
- Explicit lifecycle + events
- Reporting and queries can filter by engagement_id
- Programme issuance can target engagement instances
- Submissions permanently record engagement_id
5. Phase 2 — Operational richness (still not “project management”)
5.1 Engagement “participants” (recommended)
If you need to restrict visibility without introducing “client scope” in RLS:
- Add
engagement_participantstable that maps users to engagement roles.
Example:
engagement_participants(engagement_id, user_id, role_type, created_at)Role types:operator,verifier,viewer,manager
Important: This is authorization-layer logic, not RLS changes beyond tenant+org. RLS still enforces the hard boundary; capability checks + join checks enforce the engagement-specific access.
5.2 Engagement-level assignment templates (optional)
Allow ad-hoc tasks under an engagement while still using your K/O/E evaluation versions:
POST /engagements/\{id\}/assignments(already in MVP surface as optional)- Supports issuing:
- to a user
- to an email invite flow (if you support it)
- with run_label override (still useful for analytics)
5.3 Timeline UX contract
The timeline should be able to show:
- Engagement events
- K/O/E submissions
- Evidence/approval events (when implemented)
- Amendments/voids (ledger truth)
- “Current state” derived from latest events
5.4 Reporting read models
Add engagement dimension to reporting tables (if needed for performance):
report_submission_summary.engagement_id- Ensure projection worker populates it from submissions.
6. Phase 3 — Glass Box access (client visibility, still tenant+org boundary)
Even though you’re simplifying wording to tenant+org only, you can still offer a client/stakeholder portal scoped at engagement level using:
- magic-link viewer tokens with scope:
{ type: engagement, id: \<engagementId\> } - capabilities:
engagements.timeline.view,ledger.view, optionalledger.ratify
Rules
- Read-only by default.
- “Named / sensitive” fields are controlled by disclosure rules (Tier 1 + policy).
- All reads still go through TxManager + RLS.
7. Stretch goals — Hashing and Ratification (milestone proofs)
7.1 Hashing (state fingerprint)
Add an append-only table:
engagement_state_hashesengagement_idmilestone_key(e.g., “handover-1”)state_hash(sha256 of canonical, deterministic representation)computed_at,computed_by_user_idinputsJSONB (what was included: submission ids, versions, etc.)
Determinism rule
- Same ledger state → same hash.
- Define canonical ordering (by submission completed_at, then id, etc.).
7.2 Ratification (stakeholder sign-off)
Add append-only table:
engagement_ratifications- references
engagement_state_hashes.id - signer identity
- step-up method used
- signed_at
- optional comment
- references
Contract
- Ratification requires step-up auth (policy-driven).
- Ratification never edits state; it appends an event.
7.3 Where it fits in your system
- Engagement becomes the natural boundary for “milestone proof”.
- Submissions remain the atomic WORM records.
- Hashing/ratification prove: “this exact set of records existed and was acknowledged at this time”.
8. Implementation sequencing (why this order)
- Schema + RLS + sqlc (foundation for first-class domain)
- Service + API endpoints (create/list/update + timeline)
- Stamp engagement_id into assignments + submissions (makes it real)
- Programme apply path (integrates with existing orchestration without churn)
- Tests + projection updates (keeps reporting coherent)
- Phase 2 participants + richer timeline (operational completeness)
- Glass Box + hashing/ratification (premium defensibility features)
9. “Done” definition (MVP)
Engagements are considered first-class when:
- A new Engagement can be created and updated (status/title) with an event history
- Programme issuance can target an Engagement and produce assignments with engagement_id
- Submissions created from those assignments store engagement_id
- Timeline endpoint returns a coherent engagement story (events + submissions)
- Capability checks exist and RLS remains tenant+org enforced
- Integration tests cover the whole flow end-to-end
10. Open questions (safe defaults)
- Do we require engagement_id on all assignments?
- Default: NO (nullable), because not all work must be in an engagement.
- Do we require engagement_id on all submissions?
- Default: NO, but copy it when available. This avoids blocking existing flows.
- Should engagement status block session launch?
- Default: If status is
closed|archived, block new session creation for its assignments.
- Default: If status is
- Programme vs Engagement naming
- Internally keep Programmes as template/curriculum.
- Externally you may present Engagement as “Project/Job” depending on ICP, without changing DB names.