in progress (shipped 2026-06-24)

Spec: F85 — Pilot Instrumentation & Activation Gates

Status: in progress (shipped 2026-06-24)

Contract Graph boundary (2026-07-14): F85.cue owns the append-only activation ledger, gate/cohort computation, and three staff-only pilot API endpoints. The contract proves deterministic fixture behavior and authorization only; the /hq/pilot browser surface and real Vietnam pilot outcomes remain explicitly unproven.

This is the measurement contract behind the pitch. The investor deck’s EVIDENCE and THE ASK slides do not promise features — they promise behaviour: update completion ≥ 60%, retention 3/5 firms on 6+ days, action rate ≥ 30%, expansion ≥ 2 firms inviting a second employee, ≥ 1 paying firm, and zero invented payments/deadlines/owners. Those numbers are the gates that decide whether Lumi scales — and today none of them are measurable. There is no analytics, metrics, or activation-event model anywhere in the backend (apps/analytics / apps/metrics do not exist); F52 references a PostHog activation event but ships no backend instrumentation. This spec defines the instrumentation spine — one append-only ActivationEvent ledger plus the queries that turn it into the pilot’s pass/fail gates — so the EVIDENCE and ASK slides become executable rather than aspirational.

Aligns with field-tests.md — the authority on the gate definitions (G2–G6, §4 Gate system; §12 live-test pass bar; §16 payment ladder). Gate IDs and thresholds in this spec are quoted from that file and the pitch; F85 does not invent new ones.

1. Problem & CEO Job

Trust the signal. Of the three CEO jobs (see clearly / decide fast / trust the signal), this serves the third — but for the founder as the operator of the pilot, not the customer CEO. The pitch makes hard, behaviour-based promises to investors (“gates, not guesses”). A promise you cannot measure is a guess. Right now the team would be forced to eyeball update completion, guess at retention cohorts, and have no attributable definition of “a brief caused an action” — exactly the soft, opinion-based validation field-tests.md §0 bans (“behaviour, not opinion”). Without an instrumentation spine, every gate in the EVIDENCE and ASK slides is unfalsifiable: the team cannot honestly tell an investor whether the pilot passed, and cannot tell itself whether to scale, change the wedge, or stop. This feature is the measurement contract that makes the pitch’s evidence real and the scale decision honest.

It is not a new analytics platform. It is a thin, append-only event ledger — modelled exactly on the existing NotificationEvent and F71 ConfirmationEvent ledgers — plus the queries that compute the gates and a read-only founder dashboard. The product’s existing spine (Pulse, Brief, BriefItem, EvidenceSource, ConfirmationRequest, Subscription) already holds the underlying behaviour; F85 only observes it.

2. User Stories & Acceptance Criteria

Founder / staff (pilot operator)

  • As a founder, I want each gate’s current value vs its threshold with a clear PASS/FAIL, so that I can tell investors (and myself) whether the pilot is clearing the bar.

    • Given a pilot cohort, when I open /hq/pilot, then I see every gate (UPDATE COMPLETION, RETENTION, ACTION RATE, EXPANSION, PAYMENT, ACCURACY) with its measured value, its threshold, and a PASS/FAIL badge computed from real events — never a hardcoded or manually-entered number.
    • Given I am not staff (is_staff=False), when I request the pilot dashboard endpoint or route, then I am rejected (403 / redirect to /work/today) — this surface is never visible to customer users.
  • As a founder, I want a per-firm cohort table, so that I can read the HVAC/MEP vs fit-out SEGMENT SIGNAL the pitch’s ASK slide requires.

    • Given the cohort, when the dashboard renders, then each firm shows its active-days count, completion %, action rate, second-member-invited flag, and payment flag, with a segment tag (hvac_mep / fit_out), so the primary-wedge verdict is readable from the data.
  • As a founder, I want the ACTION RATE gate to count only attributable, observable actions, so that the 30% number is honest and not inflated by unrelated activity.

    • Given a brief item, when within the action window (default 24h) it is assigned, edited, marked done, shared via an F71 confirmation link, or converted to a task, then exactly one brief_item_acted event is recorded, attributed to that brief item (subject_type=brief_item, subject_id=<id>) — and a second qualifying action on the same item does not double-count it toward the rate.
  • As a founder, I want the ACCURACY gate measured, not asserted, so that “zero invented payments/deadlines/owners” is backed by evidence.

    • Given a sample of extracted items, when an item’s claimed payment / deadline / owner is not supported by its linked EvidenceSource span (a sampled audit), or when an owner flags an item inaccurate via F15, then an extraction_flagged_inaccurate event is recorded and the ACCURACY gate fails until the count returns to zero for the window.

Server-side emitters (existing flows)

  • As a backend service (pulse submit, brief view, assign, confirmation, billing), I want a single emit() helper, so that instrumentation is one line inside the flow I already run and cannot drift from the behaviour it measures.
    • Given a pulse is submitted, when the submit transaction commits, then a pulse_submitted event is recorded for that member/org with occurred_at set to the submit time.
    • Given any emitter is called, when the org does not exist or the actor is cross-tenant, then no event is written and the calling flow is unaffected (instrumentation never breaks a product flow).

Every acceptance criterion above maps to at least one test in §7.

3. Scope Boundaries

  • In:
    • One append-only ActivationEvent ledger model (tenant-scoped, mirrors NotificationEvent / ConfirmationEvent).
    • A server-side emit(event_type, organization, member=None, subject=None, metadata=None) helper in a new apps/metrics app, called from inside existing flows (pulse submit, brief view, brief item action, confirmation send, second-member invite, payment, F15 inaccuracy flag).
    • Gate computation: pure-query (or a cheap periodic rollup) functions that turn the ledger into the seven pitch/field-test gate values, scoped to a pilot cohort.
    • A read-only founder/staff pilot dashboard endpoint + /hq/pilot surface (gated behind F43 Admin HQ is_staff).
    • The precise operational definition of “observable action” (see §4.4) and of each gate.
  • Out:
    • Enforcement of accuracy (“zero invented payments/deadlines/owners” as a guarantee in the extraction pipeline) — that is F86 Extraction Faithfulness (to be written). F85 measures accuracy; F86 enforces it. The extraction_flagged_inaccurate event is the shared boundary.
    • A data warehouse, OLAP store, event partitioning/sharding, or a streaming pipeline — explicitly not built for load we do not have (see §11 constraints and ENGINEERING.md → Scale-Ready Design).
    • Customer-facing analytics dashboards — this surface is founder/staff only.
    • The activation flow and demo-data scaffolding — that is F52; F85 only defines and records the activation event F52 references but never instrumented.
    • The confirmation-link Loop-2 mechanism — that is F71; F85 reads its ConfirmationEvents as one input to ACTION RATE / EXPANSION, it does not duplicate them.
    • A second analytics platform. If PostHog is later used, it is an optional downstream mirror, never the source of truth (see §12).

4. Data Model

Reuses the existing event-ledger pattern; adds one model and (optionally) one cohort tag. Nothing here duplicates a model that exists.

4.1 New — ActivationEvent (the spine)

apps/metrics/models.py, extends UUIDModel, mirrors NotificationEvent/ConfirmationEvent:

class ActivationEvent(UUIDModel):
    """Append-only pilot-instrumentation ledger (F85). Never updated, never deleted in flows."""

    EVENT_TYPES = [
        ("pulse_submitted", "Pulse submitted"),            # → UPDATE COMPLETION, RETENTION
        ("brief_viewed", "Brief viewed"),                  # → activation (F52), RETENTION
        ("brief_item_acted", "Brief item acted on"),       # → ACTION RATE (attributable)
        ("item_assigned", "Item assigned"),                # → ACTION RATE input
        ("confirmation_sent", "Confirmation link sent"),   # → ACTION RATE / Loop 2
        ("second_member_invited", "Second member invited"),# → EXPANSION
        ("payment_made", "Non-zero payment made"),         # → PAYMENT
        ("extraction_flagged_inaccurate", "Extraction flagged inaccurate"),  # → ACCURACY (fail signal)
    ]

    organization = models.ForeignKey(
        "organizations.Organization", on_delete=models.CASCADE,
        related_name="activation_events",
    )
    member = models.ForeignKey(
        "organizations.OrganizationMember", on_delete=models.SET_NULL,
        null=True, blank=True, related_name="activation_events",
    )
    event_type = models.CharField(max_length=40, choices=EVENT_TYPES, db_index=True)
    # Soft reference to the subject (Brief, BriefItem, Commitment, ConfirmationRequest, Subscription…)
    # — a soft type+id, NOT a hard FK into a future-huge table (scale rule).
    subject_type = models.CharField(max_length=40, blank=True)
    subject_id = models.UUIDField(null=True, blank=True)
    metadata = models.JSONField(default=dict, blank=True)  # no PII beyond what the spine already holds
    occurred_at = models.DateTimeField(db_index=True)      # when the behaviour happened, not write time

    class Meta:
        db_table = "activation_events"
        ordering = ["-occurred_at"]
        indexes = [
            models.Index(fields=["organization", "event_type", "occurred_at"]),
            models.Index(fields=["organization", "member", "occurred_at"]),
        ]
        constraints = [
            # Idempotency / no double-count for attributable actions (e.g. brief_item_acted once
            # per (subject, day)). dedupe_key is computed by emit(); see §5.
        ]
  • Append-only. Flows only ever create(); no flow updates or deletes a row (mirrors NotificationEvent). The record is the evidence.
  • Soft subject reference, not a hard FK or M2M into the high-volume BriefItem/Commitment tables — per the scale-ready design rule: no FK into a future-huge table, no unbounded related_name reverse cascades from those tables.
  • No new PII. metadata carries only ids, the soft subject reference, and coarse flags (e.g. {"action": "assigned"}, {"segment": "hvac_mep"}). No message bodies, no customer names, no payment amounts beyond a non-zero boolean — the gate is ”≥ 1 firm pays any non-zero amount”, so a boolean paid=true suffices.

4.2 Cohort tagging (reuse, not a new model)

A pilot cohort is identified by the existing Organization and its segment. Add a single field rather than a new model (Simplicity, per F52 §4.4):

# Organization — new field
pilot_segment = models.CharField(max_length=20, blank=True, choices=[
    ("hvac_mep", "HVAC / MEP maintenance"),
    ("fit_out", "Interior fit-out"),
])

The pilot cohort = orgs with a non-blank pilot_segment. The SEGMENT SIGNAL gate splits gate values by this field. (If the wedge’s provisional-org work from F70 §4 later lands, the tag is set at provisioning.)

4.3 Aggregates — query first, rollup only if needed

Gate values are computed by pure aggregate queries over ActivationEvent for the pilot cohort (small N: 5 firms × 10–14 days). No materialized table is built now. The query module (apps/metrics/gates.py) exposes one function per gate, each returning {value, threshold, passed}:

  • update_completion(cohort) — % of (member × working-day) cells with ≥ 1 pulse_submitted.
  • retention(cohort) — count of firms with pulse_submitted or brief_viewed on ≥ 6 distinct days within the two-week window.
  • action_rate(cohort) — distinct brief_item_acted subjects ÷ distinct briefed items in window.
  • expansion(cohort) — count of firms with ≥ 1 second_member_invited.
  • payment(cohort) — count of firms with ≥ 1 payment_made.
  • accuracy(cohort) — count of extraction_flagged_inaccurate in window (gate passes iff 0).

If the cohort ever grows past hand-query scale, the upgrade is mechanical: a nightly Celery rollup into a PilotGateSnapshot table keyed by (cohort, gate, date) — not built now (no load).

4.4 Operational definition of “observable action” (ACTION RATE)

A brief drives an observable action when, within the action window (default 24h) of the brief item being delivered, the item is the subject of at least one of:

  1. assigned to a member (F25 assignment) → item_assigned then a brief_item_acted{action:"assigned"};
  2. edited / corrected (owner changes assignee, date, or text — also feeds F15);
  3. marked done / resolved (status transition to a terminal state);
  4. shared via a confirmation link (F71 ConfirmationRequest created for that subject) → confirmation_sent then brief_item_acted{action:"confirmation"};
  5. converted to a task (F05).

Each is attributable (the event carries subject_type=brief_item, subject_id) and deduplicated (one item counts once toward the rate regardless of how many qualifying actions it receives). This matches field-tests.md §11 (“owner corrected details rather than ignoring it”, “owner agrees to send external confirmation link”) and the pitch’s ”≥ 30% of briefs cause an observable action”.

5. API Contract

Emission was server-side only through 2026-07-09 — every emit() call sat inside an existing authenticated flow’s own view/task, never a dedicated client-write endpoint. 2026-07-10 (W-SYS-11 follow-up) added exactly one client-write path — a pure telemetry ingestion endpoint for a signal with no corresponding backend action to piggyback on (rage-click detection is purely client-side). Reads remain founder/staff only, under the existing F43 /api/hq/ namespace; the new write is any-authenticated-org-member, under /api/metrics/ (deliberately not /api/hq/ — it is not staff-only).

MethodPathAuth/RoleRequestResponseErrors
GET/api/hq/pilot/gates/is_staff (F43 IsStaff)?segment=hvac_mep|fit_out (optional)200 {window, gates:[{id, label, value, threshold, passed}], overall_pass}403 non-staff
GET/api/hq/pilot/cohort/is_staff?segment= (optional)200 {firms:[{org_id, name, segment, active_days, completion_pct, action_rate, second_member_invited, paid}]}403 non-staff
POST/api/metrics/events/rage-click/any authenticated org member (IsOrgMember){route, element_selector?, click_count, window_ms}204 (always — telemetry never surfaces an error to the client)401/403 unauthenticated or no valid X-Organization-ID
POST/api/metrics/events/screen-viewed/any authenticated org member (IsOrgMember){route, occurred_at} — canonical pathname + client timestamp204 (always — telemetry never surfaces an error to the client)401/403 unauthenticated or no valid X-Organization-ID
  • Emission helper (internal, not an endpoint): metrics.emit(event_type, *, organization, member=None, subject=None, metadata=None, occurred_at=None, dedupe_key=None) in apps/metrics/services.py. Called from inside existing flows after their own transaction commits (so an emit failure can never roll back the product action — emit is best-effort and swallows its own errors with a WARNING log). It computes a dedupe_key for attributable events (e.g. f"{event_type}:{subject_id}:{occurred_at:%Y-%m-%d}") and uses get_or_create(dedupe_key=...) so re-running a flow never double-counts.
  • Tenant scoping (Rule 1). emit() requires an organization and never accepts a cross-tenant member/subject (it asserts member.organization_id == organization.id). The read endpoints are staff-only and cross-org by design (the founder must see all pilot firms), but they only ever read the cohort defined by pilot_segment and never leak one firm’s events into another firm’s row — every aggregate is grouped by organization_id (the mandatory isolation test, §7 #3). No customer-tier endpoint can read another org’s events.
  • AI output: F85 produces no AI output; it measures AI output. It links to existing EvidenceSource spans only for the ACCURACY audit (read-only). No confidence score is produced.

5.1 Emitters wired into existing flows (one line each)

Flow (existing)Emits
Pulse submit (F07)pulse_submitted
First brief view (F12/F52 activation)brief_viewed
Brief item assigned (F25)item_assigned + brief_item_acted
Brief item edited/done/converted (F12/F05)brief_item_acted
Confirmation link created (F71)confirmation_sent + brief_item_acted
Second member invited (F02)second_member_invited
Non-zero payment (F24/billing)payment_made
Item flagged inaccurate (F15) or accuracy auditextraction_flagged_inaccurate
Rage-click burst detected client-side (W-SYS-11, frontend/src/lib/rageClickTelemetry.ts) → POST /api/metrics/events/rage-click/ui_rage_click_detected
Every authenticated TanStack Router pathname (frontend/src/lib/screenViewTelemetry.ts) → POST /api/metrics/events/screen-viewed/screen_viewed

The tenant ledger write remains immediate and best-effort. Its mirrored PostHog event is routine startup telemetry: it queues in memory until the post-load telemetry window instead of downloading the analytics SDK during route paint. Explicit user interactions may still initialize analytics eagerly so the interaction itself is not dropped.

6. UI / UX

  • Route: /hq/pilot — a new tab inside the F43 Admin HQ shell (staff-only; non-staff are redirected to /work/today, identical to every other /hq/* route).
  • Page component: PilotDashboardPage → GateCard (one per gate) + CohortTable (per-firm).
  • Gate cards: each shows the gate label, the measured value, the threshold, and a PASS/FAIL Badge. PASS uses text-[var(--color-success)] / soft success token; FAIL uses the destructive token — never raw green-*/red-* (Rule 4). A header strip shows overall PASS/FAIL and the window dates.
  • Cohort table: the shared <DataTable> (F66) — columns: firm, segment, active days, completion %, action rate, second member invited, paid. Segment column makes the HVAC/MEP-vs-fit-out SEGMENT SIGNAL readable at a glance.
  • States to design:
    • empty — no pilot orgs yet: “No pilot cohort yet. Tag an organization’s segment to begin.”
    • loading — honest Skeleton cards, not a fake spinner.
    • error — metrics.error_load with a retry.
    • populated — gates + cohort table.
    • no-permission — handled by the F43 route guard (redirect), never a leaky 200.
  • shadcn components: Card, Badge, Skeleton, the shared DataTable (F66). Check frontend/src/components/ui/ first; no raw Tailwind, only Lumi semantic tokens (Rule 4).
  • i18n namespace: metrics — keys in both en/ and vi/ (Rule 3):
    • metrics.pilot_title, metrics.window_label, metrics.overall_pass, metrics.overall_fail
    • metrics.gate_update_completion, metrics.gate_retention, metrics.gate_action_rate, metrics.gate_expansion, metrics.gate_payment, metrics.gate_accuracy
    • metrics.threshold_label, metrics.value_label, metrics.pass, metrics.fail
    • metrics.col_firm, metrics.col_segment, metrics.col_active_days, metrics.col_completion, metrics.col_action_rate, metrics.col_second_member, metrics.col_paid
    • metrics.segment_hvac_mep, metrics.segment_fit_out
    • metrics.empty_state, metrics.error_load
  • Server state: TanStack Query (Rule per frontend/AGENTS.md), never useEffect+fetch.
  • DESIGN.md + TASTE.md pre-flight applies; the dashboard is calm and scannable, not a wall of numbers.

7. Test Plan

#CaseTypeWhere
1emit('pulse_submitted', org, member) writes one row with correct occurred_atbackendbackend/tests/test_metrics.py::TestEmit
2emit is idempotent — same dedupe_key twice writes one row (no double-count)backendbackend/tests/test_metrics.py::TestEmit
3Cross-tenant isolation — org A’s gate values never include org B’s events (mandatory)backendbackend/tests/test_multitenancy.py::TestActivationEventIsolation
4emit rejects a cross-tenant member/subject (asserts org match), product flow unaffectedbackendbackend/tests/test_metrics.py::TestEmit
5update_completion = % of (member×working-day) cells with ≥1 pulse — correct on fixturebackendbackend/tests/test_metrics.py::TestGates
6action_rate counts a brief item once even with multiple qualifying actionsbackendbackend/tests/test_metrics.py::TestGates
7retention counts a firm only with ≥6 distinct active days in windowbackendbackend/tests/test_metrics.py::TestGates
8accuracy fails on any extraction_flagged_inaccurate, passes at zerobackendbackend/tests/test_metrics.py::TestGates
9expansion/payment gates count distinct firms, not eventsbackendbackend/tests/test_metrics.py::TestGates
10GET /api/hq/pilot/gates/ returns all gates with value/threshold/passed for staffbackendbackend/tests/test_metrics.py::TestPilotEndpoint
11Non-staff user gets 403 from /api/hq/pilot/gates/ and /cohort/backendbackend/tests/test_metrics.py::TestPilotEndpoint
12Cohort endpoint groups by org; segment filter splits HVAC/MEP vs fit-outbackendbackend/tests/test_metrics.py::TestPilotEndpoint
13Pulse submit flow emits pulse_submitted end-to-endbackendbackend/tests/test_metrics.py::TestEmitters
14Brief-item assign / confirmation-send flow emits brief_item_acted attributed to the itembackendbackend/tests/test_metrics.py::TestEmitters
15E2E: staff sees /hq/pilot gates + cohort; non-staff is redirectede2efrontend/tests/e2e/lumi.spec.ts describe("Pilot dashboard (F85)")

Backend coverage must stay ≥99% (per CLAUDE.md verify gate); new E2E describe block required. The cross-tenant isolation test (#3) is a hard gate — a metrics row that bleeds across orgs is the same class of defect as a data leak.

8. Current Behavior

No analytics, metrics, or activation-event model exists today. apps/analytics and apps/metrics do not exist (verified). The only analytics in the backend is the unrelated CRM deal-funnel analytics action in apps/crm/views.py. F52 §2 references a PostHog activation event (activation_completed) and an Admin HQ activation metric, but its §11 audit marks PostHog events as a ⚠️ gap (“Not mentioned in any BL entry”) — there is no backend instrumentation, no event ledger, and therefore no way to compute any of the pitch’s gates. The underlying behaviours are recorded by the spine (Pulse, Brief, BriefItem, ConfirmationEvent, Subscription), but nothing observes them as activation events or rolls them into gate values.

9. Error States

#ScenarioUser-visibleInternalLogRetry
1emit() raises (bad subject, DB hiccup)None — product flow unaffectedSwallowed; event simply not writtenWARNING "activation_emit_failed" with event_typeNone (best-effort; next event of that type will write)
2emit() given a cross-tenant member/subjectNoneAssertion fails; no row writtenERROR "activation_emit_cross_tenant"n/a (programming error to fix)
3Gate query has no events for the cohortEmpty/zero gate values rendered honestly (e.g. completion 0%, FAIL)n/an/an/a
4Non-staff hits /api/hq/pilot/*403 (or route redirect)Permission deniedINFO "pilot_dashboard_permission_denied"n/a
5Pilot dashboard fetch failsmetrics.error_load toast + retry button5xx surfacedERROR "pilot_dashboard_load_error"Manual retry
6No org has a pilot_segmentmetrics.empty_staten/an/an/a
  • Never fabricate a gate value, never round a FAIL up to a PASS, never hardcode a threshold or a measured value. A gate with no supporting events reads as FAIL, honestly.

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md: add ActivationEvent to the ER diagram (alongside NotificationEvent/ConfirmationEvent as a sibling append-only ledger); add the emit→ledger→gate-query request lifecycle.
  • GLOSSARY.md: add activation gate, observable action, pilot cohort, activation event.
  • F52 §2/§11: this spec is the backend instrumentation F52’s PostHog activation event was a placeholder for; cross-link (F52 emits brief_viewed via F85’s emit()).
  • F43: the pilot dashboard is a new /hq/pilot surface inside Admin HQ; cross-link.
  • F86 Extraction Faithfulness (to be written): the enforcement counterpart to F85’s ACCURACY measurement; extraction_flagged_inaccurate is the shared event.
  • ADR: none required if the ledger reuses the established NotificationEvent/ConfirmationEvent pattern. A new ADR is required only if a PilotGateSnapshot rollup or a PostHog mirror is later adopted as more than an optional sink (see §12).

11. Implementation Audit (2026-06-24)

Built (2026-06-24). Backend app apps/metrics ships the append-only ActivationEvent ledger (activation_events), the best-effort emit() helper (apps/metrics/services.py), the pure-query gate module (apps/metrics/gates.py), and the staff-only read endpoints GET /api/hq/pilot/gates/ + GET /api/hq/pilot/cohort/ (apps/metrics/views.py, mounted under the F43 /api/hq/ namespace behind IsStaff). Organization.pilot_segment tags the cohort (migration organizations/0012). emit() is wired one line each into pulse submit (apps/pulses/views.py), brief view + assign + dismiss + F15 feedback (apps/briefs/views.py), confirmation create (apps/confirmations/views.py), second-member invite (apps/organizations/views.py), and non-zero payment via transaction.on_commit (apps/billing/services/__init__.py). Frontend /hq/pilot (PilotDashboardPage.tsx) renders gate cards + the shared F66 <DataTable> cohort table with PASS/FAIL badges on Lumi success/destructive tokens, TanStack Query data, and empty/loading/error/populated states; i18n namespace metrics (en + vi). Tests: tests/test_metrics.py (TestEmit, TestGates, TestPilotEndpoint, TestEmitters) + tests/test_multitenancy.py::TestActivationEventIsolation (the mandatory cross-tenant gate); src/test/F85PilotDashboard.test.tsx; E2E describe("Pilot dashboard (F85)") in tests/e2e/lumi.spec.ts. New backend code is 100%-covered.

Constraints that bound the design (all honoured):

  • Must reuse the spine, not build a parallel analytics platform. The product’s behaviours are already recorded (Pulse, Brief, BriefItem, ConfirmationEvent, Subscription). F85 observes them via a thin ledger; it does not re-store business data.
  • Must mirror the existing append-only event-ledger pattern — NotificationEvent and F71 ConfirmationEvent: UUIDModel, organization FK, typed event_type, soft subject reference, metadata JSON, indexed timestamp, create-only.
  • Must reuse EvidenceSource for the ACCURACY audit (compare extracted claim ↔ evidence span); never invent a parallel provenance store.
  • No PII beyond what the spine already holds — ids, soft subject refs, coarse flags only; a non-zero payment is a boolean, never an amount in the ledger.
  • Scale-ready but not over-engineered (ENGINEERING.md): soft subject ref (no FK into future-huge BriefItem/Commitment), id/timestamp indexes for keyset reads, no warehouse, no partitioning, no rollup table — the rollup upgrade (PilotGateSnapshot) is left mechanical and unbuilt because the pilot load (5 firms × 2 weeks) does not warrant it.
  • Tenant isolation is a hard gate, even though the dashboard is intentionally cross-org for staff: every aggregate groups by organization_id, and emit() refuses cross-tenant subjects.
  • Acceptance is the pilot itself. Like F70/F71, F85 is validation-gated: the spine is “done” when it correctly computes the gates on real pilot data, and the gates’ verdicts — not a green test suite alone — are what the EVIDENCE/ASK slides report.

12. Open Questions / Decisions

  • Source of truth: self-hosted ActivationEvent vs PostHog. Recommendation: self-hosted ActivationEvent is the single source of truth. Reasons: (a) the gates must be computed inside a tenant-scoped, auditable store the team controls — an investor-facing number cannot depend on a third-party SDK that F52’s audit shows was never actually wired; (b) tenant isolation and “no PII” are enforceable in our own model and reviewable in code; (c) the ledger doubles as the audit trail for ACCURACY. PostHog (or similar) may be added later as an optional downstream mirror for funnel exploration only — fed from ActivationEvent, never read as the gate source. Adopting a mirror as anything more than an optional sink requires an ADR.
  • Working-day definition for UPDATE COMPLETION — calendar working days (Mon–Sat for VN contractors?) vs each firm’s own active days. Decide with the field team; default to Mon–Sat, configurable per cohort, recorded in gates.py.
  • Action window for ACTION RATE — default 24h from brief-item delivery; tune against the field-test live-test data (field-tests §12).
  • ACCURACY audit sampling rate — what fraction of extracted items get a human/automated evidence-span audit, and how the audit emits extraction_flagged_inaccurate. Resolve jointly with F86 when that spec is written.
  • Post-funding thresholds. The ASK slide raises the bars (retention ≥ 3/5 active 8+ weeks; expansion 30–50% of firms with 2+ employees; REACH 100–250 paying companies, $10–25K MRR; known UNIT ECONOMICS / contribution margin). The same ledger computes these by widening the window and adding payment_made amount-bucket metadata + a contribution-margin input; tracked here as a forward gate, built when the pilot gates pass — not now.

13. Bug / Change Log

2026-07-16 — Screen-view analytics honors the deferred telemetry window

Type: Performance + measurement integrity

Summary:

  • Automatic screen_viewed events now queue for PostHog until scheduled telemetry initialization; the tenant-scoped F85 ledger write is unchanged.
  • Explicit interaction events retain eager analytics initialization.
  • The empirical route-transfer harness reports deferred PostHog/Sentry bytes separately so an 8-second timer cannot randomly change a route budget.

2026-07-16 — General route backtrack telemetry closes the W-SYS-11 gap

Type: Feature (frontend + backend)

Summary:

  • Added screen_viewed to the append-only ActivationEvent ledger and a best-effort POST /api/metrics/events/screen-viewed/ endpoint. The endpoint is tenant-scoped by IsOrgMember, accepts only a canonical pathname and client timestamp, and always returns 204 so telemetry cannot interrupt navigation.
  • screenViewTelemetry subscribes to TanStack Router onResolved events and mirrors authenticated route changes to the F85 ledger plus PostHog. The event contains no query strings, hashes, or user content.
  • Added find_screen_backtrack_loops() and included its count/details in frustration_report; GAPS is now explicitly empty. Brief-specific backtracks remain available for continuity.
  • Added backend endpoint/query tests and a frontend helper test, plus the migration and API/flow documentation. Production telemetry must still be observed after deployment before PM scoring or a release result changes.

2026-07-10 — Rage-click telemetry closes the W-SYS-11 gap (first client-write endpoint)

Type: Feature (frontend + backend)

Summary:

  • docs/tests/evidence/web/W-SYS-11/report.md named ui_rage_click_detected as a fully open gap: no client event existed for rapid repeat clicks, and even PostHog’s own autocapture (if firing) lands only in PostHog’s cloud with no per-organization/member scoping this codebase can query.
  • Added ("ui_rage_click_detected", "Rage-click burst detected") to ActivationEvent.EVENT_TYPES (migration 0004_alter_activationevent_ event_type). No dedupe_key — every burst is distinct, never one-per-day like the attributable event types.
  • Added frontend/src/lib/rageClickTelemetry.ts — a document-level, capture-phase click listener detecting >=4 clicks within 1s inside a 32px radius, debounced to ONE report per burst. Reports via both established frontend product-event paths: posthog.capture('ui_rage_click_detected', …) (dynamically imported, same singleton ChatPage.tsx also calls .capture() on) for the analytics dashboards, and the new POST /api/metrics/events/rage-click/ (§5) for the tenant-scoped ActivationEvent ledger frustration_report reads. Target descriptor is tag + data-testid only — never text content (privacy).
  • Added apps.metrics.frustration.find_rage_click_clusters() — reads bursts back ranked by click intensity (worst offender first, ties by recency). build_report()/render_report_text() now surface a rage_click_cluster signal; the rage_click entry is removed from GAPS (closed, not a gap anymore) — backtrack_loop_general is the one remaining honest gap.
  • Tests: frontend/src/test/_shared/rageClickTelemetry.test.ts (11, injected deps), backend/tests/metrics/test_rage_click_view.py (5, including tenant-isolation and unauthenticated-rejection cases), backend/tests/metrics/test_frustration_report.py (+4 new TestRageClickClusters cases + the existing gap/merge tests updated for the now-closed gap). apps/metrics/frustration.py stays at 100% statement coverage.
  • See MVP_SCOPE.md — this closes a query-layer gap on an already-shipped feature; no matrix status change (F85 status unchanged).

2026-07-02 — Added missing confirmation-send emitter test (§7 row 14)

Type: Test

Summary:

  • Added TestEmitters::test_confirmation_send_emits_brief_item_acted to backend/tests/test_metrics.py. §7 row 14 claimed the confirmation-send flow emits brief_item_acted, and the code does emit it (confirmations/views.py lines 86-94), but TestEmitters had no test covering that code path (grep for confirmation returned nothing). The new test creates a Commitment, POSTs to /api/confirmations/, and asserts that ActivationEvent rows exist for both confirmation_sent and brief_item_acted (with subject_id=commitment.id and metadata.action="confirmation"). Tenant scoping respected via pilot_org/pilot_member fixtures.

Impact:

  • No production code changed. Closed the gap between §7 row 14 and the test suite. Coverage: 97.97% (above the 97% threshold).

2026-07-02 — Spec drift corrected (batch 5)

Type: Docs

Summary:

  • Status updated from draft to in progress (shipped 2026-06-24) to match §11 (backend apps/metrics + frontend /hq/pilot + all tests built and green 2026-06-24; validation-gated on real pilot data per §11 “Acceptance is the pilot itself”).
  • All §2 ACs marked [x] per §11:
    • Founder AC1: pilot dashboard with gate cards + PASS/FAIL badges — confirmed by §11 frontend description + §7 row 10 (TestPilotEndpoint, green).
    • Founder AC2: non-staff 403 / redirect — confirmed by §7 row 11 (green) + E2E describe block.
    • Founder AC3: cohort table with segment tag — confirmed by §7 row 12 (green).
    • Founder AC4: brief_item_acted deduplication (one per item) — confirmed by §7 row 6 (green)
      • row 14 (TestEmitters, green).
    • Founder AC5: extraction_flagged_inaccurate via F15 feedback + ACCURACY gate — confirmed by §11 “F15 feedback” wiring in apps/briefs/views.py + §7 row 8 (green).
    • Emitter AC1: pulse_submitted on submit commit — confirmed by §11 + §7 row 13 (green).
    • Emitter AC2: cross-tenant reject + flow unaffected — confirmed by §7 row 4 (green) + TestActivationEventIsolation.

2026-06-24 — Implemented F85

Type: Feature

Summary:

  • Shipped the pilot instrumentation spine: apps/metrics with the append-only ActivationEvent ledger, the best-effort emit() helper, the pure-query gates.py (update_completion, retention, action_rate, expansion, payment, accuracy + cohort builder/summary), and the staff-only endpoints GET /api/hq/pilot/gates/ and GET /api/hq/pilot/cohort/. Added Organization.pilot_segment (migration). Wired emit() into the eight §5.1 flows. Added the /hq/pilot founder dashboard (PilotDashboardPage, gate cards + shared <DataTable> cohort table, metrics i18n namespace en+vi).

Reason:

  • The pitch’s EVIDENCE/ASK gates were unmeasurable (no metrics/activation-event model existed). F85 makes them executable, aligned with field-tests.md gate IDs/thresholds.

Impact:

  • New apps/metrics + one Organization field + a staff-only HQ surface. No customer-facing change. Tenant isolation enforced even though the staff dashboard is cross-org by design.

Tests:

  • tests/test_metrics.py (TestEmit, TestGates, TestPilotEndpoint, TestEmitters) + tests/test_multitenancy.py::TestActivationEventIsolation; src/test/F85PilotDashboard.test.tsx; E2E describe("Pilot dashboard (F85)"). New backend code 100% covered; ruff clean; tsc 0 errors; design-token check clean.

Notes:

  • Thresholds quoted from field-tests.md / pitch (completion ≥60%, retention ≥3 firms on 6+ days, action_rate ≥30%, expansion ≥2 firms, payment ≥1, accuracy == 0). Working day default Mon–Sat; window 14 days. emit() swallows its own errors (never breaks a product flow) and refuses cross-tenant member/subject. payment_made is a boolean (no amount, no PII).

2026-06-24 — Spec created

Type: Feature

Summary:

  • Created F85 to define the pilot instrumentation spine: an append-only ActivationEvent ledger (mirroring NotificationEvent/ConfirmationEvent), a server-side emit() helper wired into existing flows, pure-query gate computation for the pitch’s seven behaviour-based gates (UPDATE COMPLETION, RETENTION, ACTION RATE, EXPANSION, PAYMENT, ACCURACY, SEGMENT SIGNAL), and a staff-only /hq/pilot dashboard. Defined “observable action” precisely and attributably.

Reason:

  • The investor pitch’s EVIDENCE and ASK slides promise behaviour-based gates, but no analytics / metrics / activation-event model exists in the backend (apps/analytics / apps/metrics absent; F52’s PostHog activation event is an unbuilt placeholder). The gates are unmeasurable — and a promise you cannot measure is a guess. This spec is the measurement contract that makes the pitch executable, aligned with field-tests.md gate IDs/thresholds.

Impact:

  • New draft feature F85 in the registry. Adds one model + one app (apps/metrics) + one Organization field (pilot_segment) + a staff-only HQ surface. No customer-facing change. Tenant isolation is a hard gate despite the cross-org staff dashboard.

Tests:

  • Test plan defined (§7); not yet implemented.

Notes:

  • F85 measures; F86 enforces the ACCURACY gate — shared event is extraction_flagged_inaccurate. Reuse the spine and the existing event-ledger pattern; do not build a parallel analytics platform. Self-hosted ActivationEvent is the source of truth; any PostHog use is an optional downstream mirror (ADR required to be more than a sink). Validation-gated: the gates’ verdicts on real pilot data are the deliverable, not a green test suite alone.