← Design · c147

finding-shape.md

c147 Phase 0: the finding's shape, the common tongue

Written: 2026-11-13. Reader: Mark (review stop 1); the qnative executor in Phase 5 (types::findings and its drift test read this file); c133 (§9). Status: a proposal in the wire shape, written from the lab's model (app/data/governance-fixture.ts: Finding, FindingEvent, EventKind, Resolution, FindingState) with what this cycle adds marked new. Nothing is built in qnative and nothing changes on the plane but R4 (Q4, Q6).

0. The rule

One object, two directions. A finding is minted where the activity happened, on the endpoint, and never changes. Everything after is an event appended by one of three writers: the endpoint (qcontrol on this machine), the person at the keyboard, or the plane (an operator, a policy, a re-evaluation). No side edits another's event. Status is derived by folding the events, as the lab already does.

The common tongue

1. The finding (immutable)

Finding {
  id:                      FindingId     // minted on the endpoint from the fingerprint (§5)
  policy_id:               PolicyId
  config_version:          u32           // the Policy Config version live at detection (c121)
  outcome:                 "enforced" | "observed"   // c121 D16: stamped at detection, never changes
  session_id:              SessionId
  subject:                 { user, team, device, agent, account? }
  resource:                { kind: "mcpServer" | "account" | "provider" | "path" | "dataClass", value }
  observed_at:             Timestamp
  severity_at_generation:  "critical" | "high" | "medium" | "low" | "informational"
  evidence:                string        // one line, the sensor's words ("Read of ~/.ssh/id_rsa denied at 10:12; the agent stopped")
}

The lab's deprecated policyVersion is left out. Nothing here is mutable; a later severity override or exception lives in the plane's Changes and reaches the finding as a reevaluate event.

2. The event (append-only)

FindingEvent {
  id:           EventId                  // minted by the writer; replays are idempotent by id
  finding_id:   FindingId
  at:           Timestamp
  from:         "endpoint" | "person" | "plane"   // new: which side wrote it
  actor:        string                   // "qcontrol on nina-mbp", the person's name, "Dana (security admin)", "qdash"
  kind:         EventKind                // §3
  origin?:      "manual" | "re-evaluation" | "system"   // the lab's: who moved it (c121)
  to_status:    "open" | "resolved"      // the status after this event; an event that moves nothing repeats the current one
  resolution?:  Resolution               // §4, when to_status is resolved
  reason_code?: "false-positive" | "by-design" | "security-testing" | "other"   // a manual resolve (B2)
  reason?:      string
  cause_id?:    ChangeId                 // the plane's Change behind a re-evaluation
  payload?:     object                   // per kind (§3): the training, the follow-up's words, the note to IT
}

from is new and says where the event was written; the lab's origin keeps its meaning (a person, a re-evaluation or the system). The two are independent: a prevented event is from: endpoint, origin: system; a resolve is from: plane, origin: manual.

3. Event kinds, by writer

KindFromMoves statusResolutionIn the labPayload, what it is
detectendpoint→ open✓the finding is born
preventedendpoint→ resolvedpreventednew (R4, Q4)written in the same moment as detect when the outcome is enforced: the activity was blocked, so the finding is born resolved
recurredendpointnonew (§5){ count, last_at }: the same policy, resource and person again; one finding, not a thousand
asked-itpersonnonew{ note, contact }: the person's question with the finding attached; in local mode the contact is qcontrol's it_contact (c4)
asked-accesspersonnonew{ note }: a request for an exception; the plane answers with reevaluate → allowed if granted
acknowledgedpersonnonewanswers a follow-up that asks for it
reported-mistakepersonnonew (review stop 2, call 7){ note }: the person says the finding is wrong; the plane resolves it with the reason False positive, or doesn't
training-completedpersonno (c133 D9)✓c133's training.completed record (§9)
training-assignedplanenonew`{ training_id, training_version, title, minutes, assigned_by: "policy"
follow-upplanenonew{ words, sender_label, author, requires_ack }: HQ's words, shown as sent (c137 D6); replaces the censure and the enforcement notice from the plane
resolveplane→ resolvedmanual · training-completed✓with a reason (B2); or, by the policy itself (origin system) when the person completes the training it assigned (review stop 3, 6a), "Resolved: training completed"; HQ can reopen
reevaluateplane→ resolvednow-enforced · policy-changed · allowed✓a Change made the finding stop applying (c121 D2, D16); allowed is how an exception granted on request lands
reopenplane→ open✓
ignore, revoke-ignore, ignore-expired, enforce-confirmedc114 world onlynot in the shape: the Oct 19 model removed Ignore on a finding (B3) and c121 D16 replaced "resolve as Enforced"

The status rule: only the endpoint (prevented) and the plane move a finding's status. The person's events are proof and conversation, never a verdict on their own finding, which is c133 D9 generalised. The Gene story still closes: the person finishes the training, the record arrives, and the plane resolves the finding (an operator, or a policy rule that resolves on completion; review stop 3).

4. Resolutions

Phase 4 (2026-11-13): prevented is in the prototype's model. foldFinding() in governance-fixture.ts writes the prevented event right after detect for every Enforced finding, so S1, S2, S3, Reports and the lab's qdash copy all read it (review stop 2, call 2; c121 D3 superseded).

prevented (new) · training-completed (new, review stop 3, 6a) · now-enforced · policy-changed · allowed · manual. The lab's ignore and enforced stay in the c114 world only. "Resolved: prevented" is the operator's label; the person's words are Phase 3's.

5. Identity: the fingerprint

  • The id is minted on the endpoint: F- + a hash of policy_id · resource.kind · resource.value · subject.user · subject.device, plus the window below. The same activity seen twice by the same person on the same machine is one finding. qdash's finding_key (control · scope kind · scope id) has no person in it because qdash projects findings across the fleet; this one belongs to a person on a machine.
  • The window. Observed: one finding while it is open; a repeat appends recurred; a repeat after it resolved mints a new finding. Prevented: born resolved, so the window is the day; repeats that day append recurred. An agent blocked a thousand times in an afternoon is one prevented finding with count: 1000.
  • Event ids are minted by their writer (E- + ULID), so a queue replayed after a dropped connection writes nothing twice.

6. Up and down

GoesWhenRule
Up (R5)every finding with its endpoint and person eventswhen connected; queued in order while offlinethe endpoint never writes from: plane
Down (R3)the plane's events on this endpoint's findingson syncthe plane never writes from: endpoint or from: person
Local modenothing leaves; asked-it opens the IT contactalwaysthe app raises findings itself from the sensor's events, with a port of the lab's detector against the local configuration's policies (review stop 3, 6c); qcontrol stays as it is

Because nothing is edited, the two sides never disagree about what happened, only about what has arrived. In the native app the nozzle's three sources answer the same shape: synthetic from the worlds (Phase 1, derived by the lab's detector), plane from the lab's world (the prototype is the plane, Q6), local from the app's own detector over the sensor's events (review stop 3, 6c; Phase 5).

6a. What the plane says to the person besides events (Phase 4)

  • /api/plane-settings: { noise_default, set_for }, the noise position the company chose for the person's team (review stop 2, call 6); the person's own choice wins over it, Quietly when there is no plane.
  • why_plain on each policy (in /api/policies): why the policy covers this, in the person's words; HQ's own whyPlain wins, then Qpoint's default (WHY_PLAIN in governance-fixture.ts), then the practitioner's why (call 5).

In the lab these come from app/data/plane-c147.ts (the plane's moves, matched to a world's derived findings by policy and resource, and the per-team noise defaults), merged by scripts/derive-world-findings.mts.

7. Two readers, one record

Field or valueThe operator reads (the lab, today)The person reads
outcome: enforcedEnforcedPhase 3 (c137's draft: Stopped in time)
outcome: observedObservedPhase 3 (c137's draft: Noticed)
resolution: preventedResolved: preventedPhase 3: nothing to fix
follow-upthe message sent, its acknowledgementfrom the sender label, as HQ wrote it
S1's "Why it violates"as isPhase 3: why your company's policy covers this

The record is the same; only the words differ by reader.

8. c137's message types, mapped

c137 typeBecomes
enforcement-notice (plane and local)the finding itself (detect, plus prevented when blocked); the OS notification announces it at the person's noise level
censurea follow-up event when it is about a finding; otherwise shelved (HQ's email and Slack)
training-assignmenttraining-assigned
training-completedtraining-completed (unchanged)
acknowledgementacknowledged
contact-itasked-it
identity-confirmationStatus: standalone or connected (f8d58bc9)
sensor-stateStatus and the tray icon (c131)
hq-notice, responsibility, diagnostics-request, diagnostics-sent, reply, connect-request, grant-noticeshelved with c137; HQ's own channels, the plane's Endpoints and audit log, c136's surface

Nine of c137's fifteen types fold into the finding or into Status; the rest were never about a finding.

9. c133, re-homed

c133's event-shape.md assumed c137's messages. Read against this shape:

  • §1 step 1: the assignment is a training-assigned event on the finding (from: plane), not a training-assignment message; its audit row replaces message.sent.
  • §1 step 3: completion is the training-completed event, as before. The two steps that ended the assignment message and answered its thread in the operator's inbox fall away: the operator reads the record on the finding (S1's history, the Corrections block) and in Reports.
  • §2: message_id: MessageId becomes assignment_event_id: EventId, the training-assigned event it answers. D8's "in the assignment's thread" reads as "on the finding".
  • Nothing else in c133 changes. Its file is not rewritten; c133's record points here.

10. Calls for review stop 1

Ruled 2026-11-13 (Mark, "Take the defaults", review-1.md): calls 1–4 as their defaults below; call 5 stays open until Peterson.

  1. from as the writer's field, beside the lab's origin. Default: yes.
  2. The person never moves status (c133 D9 generalised). Default: yes; the plane resolves.
  3. The window: open-while-open for Observed, the day for prevented, repeats as recurred. Default: as written. Phase 1's data point: in the pilot world the day window gives Priya 42 prevented findings in 90 days (Cursor on 19 days, Codex CLI on 23). The alternative, one prevented finding per policy and resource while its mode holds, would give two, each with a count; the person's view groups them either way (S2's grouping).
  4. prevented as both an event kind and a resolution. Default: yes; the operator's list filters on the resolution, the history shows the event.
  5. A policy rule that resolves on training completion (the Gene story closing without an operator). Default: open until review stop 3, with Peterson.

Qpoint Brand Style Guide