Status: Accepted
Date: 2026-05-08
Deciders: Ashley (sole owner). AI implementors read this as authoritative.
Depends on: concept-locked.md §"Analysis", concept-locked.md §"Pattern persistence", PRD.md §P0 Patterns, adrs/ADR-001-stack-and-build-infra.md §Q1 (:core-storage ownership), adrs/ADR-002-multi-lens-extraction-pattern.md (recurrence_link predicate, observation-generation prompt)
Validated by: PRD §"Pattern surfacing" acceptance criterion (≥10 entries → ≥1 cross-entry pattern surfaces, sourced, no interpretive overlay)
concept-locked.md, PRD.md, and AGENTS.md all describe what pattern detection produces. None of them describe how it works. The CLI agent will pick something in Phase 3 whether we record it or not, and pattern detection is the most visible "the app is smart" beat in the demo. Ad-hoc Phase-3 code will get rewritten three times before submission.
What is already locked elsewhere (do not change):
| Decision | Source |
|---|---|
| Detection runs every 10 entries (hardcoded for v1) | PRD.md §P0 Patterns |
| Pattern threshold = ≥3 supporting entries | PRD.md §P0 Capture loop |
Pattern actions = dismiss / snooze / mark-resolved (P0) |
PRD.md §P0 Patterns |
| Snooze duration = 7 days, fixed | ux-copy.md §"Locked v1 behavior" |
| Pattern-callout cooldown = 3 entries (callout only, not detection) | concept-locked.md §Analysis |
| Pattern claims must be sourced (counts, dates, snippets, tags, field evidence) | AGENTS.md guardrail 12 |
| No feelings / motivation interpretation | concept-locked.md §"Pattern persistence" |
pattern_id is deterministic (pattern engine emits stable IDs) |
adrs/ADR-002 resolver predicate for recurrence_link |
| Pattern names are model-generated; no user rename in v1 | ux-copy.md §"Locked UX Decisions" |
What this ADR closes:
- The set of pattern primitives the engine recognizes in v1 (what counts as a pattern at all).
pattern_idgeneration — content-addressable hash of which inputs, in what order.- The matching predicate — when a new entry "supports" an existing pattern.
- The persisted shape — ObjectBox
Patternentity fields, lifecycle states, transitions. - The cooldown semantics — global vs. per-pattern; how callouts interact with snooze/resolve.
- Re-eval and dismissal interactions — what happens when supporting entries change after the user took an action.
Ship five pattern primitives in v1, all sourced and content-addressable. Pattern detection is a deterministic Kotlin pass over entries from the last 90 days, run after every 10th entry. Patterns persist in ObjectBox keyed by content hash; the entity carries lifecycle state (active / dismissed / snoozed / resolved) plus the supporting-entry references needed for the source list. Cooldown is global, not per-pattern. Mark-resolved is sticky — new evidence does not re-open a resolved pattern.
Five kinds. Each emits one Pattern row when its threshold trips. Each is independent — the same entry can support multiple patterns.
pattern_kind |
Signature | Threshold | Example callout (Witness tone) |
|---|---|---|---|
template_recurrence |
template_label |
≥3 entries with the same label in the last 90 days | "Fourth Aftermath in twelve. Worth noting." |
tag_pair_co_occurrence |
(template_label, sorted tag pair) |
≥3 entries containing both tags within entries of that label | "Tuesday meetings have a body count. Standup + crashed across four entries." |
time_of_day_cluster |
("goblin", null) (sole bucket in v1) |
≥3 entries between 00:00–05:00 local time in the last 30 days | "Three goblin hours this week. Same admin loop." |
commitment_recurrence |
("commitment", topic_or_person) |
≥3 entries with stated_commitment.topic_or_person matching |
"You've logged commitments about her in five entries. Last on May 2." |
vocab_frequency |
("vocab", token) |
A single normalized token appears in ≥N entries with ≥2 distinct contexts (N=4 in v1) | "'Tired' is doing six different jobs this month." |
Why these five. They map directly to the demo beats already promised in concept-locked.md §Analysis and the ux-copy.md Pattern Detail / Roast examples. Anything beyond these is ../backlog.md territory.
Why tag pairs and not tag triples or arbitrary subsets. Pair enumeration is O(t²) per entry where t is tag count; triples are O(t³) and the resulting patterns rarely earn their visual real estate in a 5-min demo. Triples are a ../backlog.md candidate.
Why a 90-day rolling window for template_recurrence and tag_pair_co_occurrence but 30-day for time_of_day_cluster. Behavioral clusters drift. Witness saying "fourth Aftermath" feels honest when it's "fourth in twelve recent entries"; "fourth Aftermath in your entire history of 200 entries" reads as nagging. Goblin-hours specifically is meant to catch current sleep patterns, hence the tighter window.
Why no machine-learning-shaped detection in v1. Counting is sourced and explainable. A learned pattern model would need training data we don't have, and AGENTS.md guardrail 12 would invalidate any pattern the engine couldn't justify with specific evidence.
Content-addressable. The hash inputs are exactly the signature columns from the table above, serialized as a stable JSON object, then SHA-256.
// pseudocode
val signature = mapOf(
"kind" to pattern.kind.name,
"key" to pattern.signatureKey, // sorted, normalized
)
val pattern_id = sha256(Json.encodeToString(signature)) // hex stringStability rules:
- Tags inside the signature are normalized (lowercase, kebab-case, sorted) before hashing. Adding a new supporting entry never changes the hash, because the hash inputs are the defining signature, not the evidence.
pattern_kindis part of the hash, so atemplate_recurrenceforaftermathand avocab_frequencyforaftermathcannot collide.- Vocabulary tokens are stemmed and lowercased before hashing (
tireds,tired,Tired→tired).
Why content-addressable instead of an autoincrement primary key.
- ADR-002's
recurrence_linkresolver predicate requires deterministic IDs across re-eval and across the cold-start sweep. A random UUID makes "this entry matches the same pattern as before" untestable. - Content addressing means re-running the pattern detection over the same data produces the same IDs. Convergence resolver (ADR-002) and pattern detection share this invariant.
- Markdown source-of-truth (
architecture-brief.md§"Markdown Entry Shape") storesrecurrence_linkaspattern_id. If we restored from markdown, the ObjectBox cache would rebuild with the same IDs.
Run on every new entry, not only on detection-trigger entries. Cheap; bounds extraction recurrence_link per ADR-002.
pattern_kind |
Predicate |
|---|---|
template_recurrence |
entry.template_label == pattern.signature.label |
tag_pair_co_occurrence |
entry.template_label == pattern.signature.label AND pattern.signature.tag_pair ⊆ entry.tags |
time_of_day_cluster |
entry.timestamp.localHour ∈ [0,5) |
commitment_recurrence |
entry.stated_commitment != null AND entry.stated_commitment.topic_or_person == pattern.signature.topic_or_person |
vocab_frequency |
entry.tags ∋ pattern.signature.token OR entry.entry_text contains the token (stemmed match) |
Subset semantics for tag pairs. The pair is required as a subset, not an exact match. An entry tagged {tuesday-meeting, standup, flattened} supports the pattern signature (aftermath, [standup, flattened]) even though tuesday-meeting is also present. This is what makes the pattern read as "post-standup crash across four entries" instead of breaking when one entry has an extra tag.
Vocabulary-frequency context check. The "≥2 distinct contexts" requirement of vocab_frequency is computed from the supporting set, not the predicate. The predicate just identifies candidate entries; the threshold step (next section) enforces the diversity requirement so a single long sentence using "tired" four times does not become a pattern.
1. Load all entries from the last 90 days, indexed by template_label and by id.
For time_of_day_cluster, also load the last 30 days separately.
2. For each pattern_kind, enumerate candidate signatures over the loaded entries.
3. For each candidate signature, count supporting entries (the matching predicate above).
4. Apply per-kind threshold (mostly ≥3).
5. Compute pattern_id from the signature.
6. Upsert into the Pattern store:
- If pattern_id is new → INSERT with state=active, generate model-emitted title/observation
- If pattern_id exists with state=active → UPDATE supporting_entry_ids, last_seen_timestamp, latest_callout_text
- If pattern_id exists with state=snoozed AND now ≥ snoozed_until → flip state to active, UPDATE
- If pattern_id exists with state=snoozed AND now < snoozed_until → UPDATE supporting set silently, do not re-surface
- If pattern_id exists with state=dismissed OR resolved → UPDATE supporting set silently, do not re-surface
7. For each pattern that flipped to active in this run, emit a Patterns-list row (P0 demo beat).
8. For the entry that triggered detection, ask the pattern engine for one matching active pattern (if cooldown allows) → that becomes the pattern-enhanced callout appended to the per-entry observation.
Step 6's "UPDATE silently" is the key product call. A dismissed pattern still grows its supporting set in the background, so if the user un-dismisses it later (v1.5 affordance) the count is honest. In v1, dismiss is one-way; the count just keeps quietly.
Pattern title generation (step 6 INSERT). When a new active pattern lands, generate a short title via one short model call using the persona-injected observation-generation prompt (ADR-002 §3). Title is ≤24 chars (Tuesday Meetings, Goblin Hours, Invoice Email). Persisted on insert; never regenerated in v1.
Detection cost. Worst case at 200 entries with average 8 tags each:
template_recurrence: 6 candidate signatures (the closed enum). 200 entries scanned. Trivial.tag_pair_co_occurrence: ≤6 × C(8,2) = 168 candidates per entry, ~33,600 candidate-entry pairs total. Bounded.time_of_day_cluster: one signature, scan-once.commitment_recurrence: one signature per distinct topic_or_person seen. ~10–30.vocab_frequency: token frequency table overentry.tags ∪ entry_textstems.
All five together run in ~tens of milliseconds at v1 scale on the reference S24 Ultra. No incremental aggregates needed; full recompute every 10 entries is fine. Pre-aggregating is a ../backlog.md candidate when entries cross 1000.
@Entity
data class Pattern(
@Id var id: Long = 0, // ObjectBox PK, internal
@Index @Unique var patternId: String = "", // SHA-256 hex, the content-addressable id
var kind: String = "", // pattern_kind enum name
var signatureJson: String = "", // exact bytes that were hashed (debug + dedup audit)
var title: String = "", // model-generated, ≤24 chars
var templateLabel: String? = null, // denormalized for filtering; null for vocab/goblin
var firstSeenTimestamp: Long = 0, // when threshold was first crossed
var lastSeenTimestamp: Long = 0, // most recent supporting entry timestamp
var supportingEntryIds: ToMany<Entry> = ..., // ObjectBox relation
var state: String = "active", // active | dismissed | snoozed | resolved
var snoozedUntil: Long? = null, // unix-ms; null when state != snoozed
var stateChangedTimestamp: Long = 0, // when user last took action; 0 = never
var latestCalloutText: String = "", // appended to per-entry observation when this pattern fires
)Why signatureJson alongside patternId. Debugging hash collisions and verifying pattern-engine determinism without re-running the detector. Cheap storage, paid back the first time a re-eval doesn't match.
Why templateLabel is denormalized. UI filtering by template (P1: History filter by tag, template, or date range per PRD.md) is a frequent query. Denormalizing avoids parsing signatureJson on every list query.
Why no kind-specific subtypes. Single-table inheritance. Five kinds is small enough that a flat shape stays readable. ObjectBox migrations get cheaper too.
NEW (computed) → active when threshold ≥3 first crossed
active → dismissed user tapped Dismiss
active → snoozed user tapped Snooze 7 days; sets snoozed_until = now + 7d
active → resolved user tapped Mark resolved
active → below_threshold Re-eval drops support count <3 (internal only, not user-facing — see §"Re-eval interaction")
below_threshold → active later entry restores support count ≥3 (idempotent re-emerge)
snoozed → active snoozed_until passed AND a detection run sees the pattern still meeting threshold
snoozed → active user tapped Un-snooze (Patterns view exposes this)
snoozed → dismissed user tapped Dismiss while snoozed (skip the un-snooze step)
dismissed → (terminal) no transition out in v1
resolved → (terminal) no transition out in v1; new evidence accumulates silently per "UPDATE silently" rule
Why dismissed and resolved are terminal in v1. Adding "un-dismiss" or "auto-reopen on stronger evidence" doubles the state-machine surface and is invisible in the demo. The user has agency to act on a pattern; if they kill it, they killed it. Reopening is a ../backlog.md candidate (similar to reeval-auto-promote).
Auto-promotion of snoozed → active. Detection step 6 handles this. A user who snoozed Tuesday Meetings 7 days ago will see it return on the next detection run after the snooze expires if the pattern still has fresh supporting entries. If the pattern has decayed (no new supporting entries in the last 30 days), it stays snoozed silently — re-surfacing a stale pattern is more annoying than helpful.
Mark-resolved is sticky for the demo. Concept-locked says the app respects user agency; this is the cleanest expression of that. Pattern Detail still shows the sourced evidence (P1 history filter applies), but the Patterns list does not include it.
Pattern detection runs every 10 entries and updates the Pattern store unconditionally. The callout — the appended line on a per-entry observation, e.g., "Witness also noticed: this is the fourth Aftermath in twelve" — has a 3-entry global cooldown.
Mechanics:
- Persist
lastCalloutEntryId: Long?andlastCalloutTimestamp: Long?as singleton settings (one row per app, not per pattern). - After a callout fires on entry
E, suppress callouts on the next 3 entries even if active patterns match. - Suppressing a callout does not suppress the per-entry observation itself. Per-entry observations always run (ADR-002 §3).
Why global, not per-pattern. Concept-locked says "Cooldown of 3 entries on the pattern-callout part only" (singular). Per-pattern cooldown lets two patterns fire callouts on the same entry, which is exactly the noise we are trying to suppress. Global is one tunable knob, not five.
Pattern selection on a callout-eligible entry. When multiple active patterns match, pick the one with the highest supporting_entry_count. Ties broken by lastSeenTimestamp (most recent first). This makes the callout track the strongest pattern, not a random one.
Per ADR-002, Re-eval re-runs the 3-lens pipeline on a stored entry and may change tags or template_label. That can change which patterns this entry supports.
Contract:
- After Re-eval commits new field values, recompute the entry's pattern matches against the existing Pattern store.
- For patterns the entry no longer supports, remove from
supportingEntryIdsand decrement implied count. If supporting count drops below 3, the pattern stays in the store but transitions to a hidden state (new value:state="below_threshold"— internal only, not user-facing). It does not get deleted; if a later entry restores the count, it un-hides. - For new patterns the entry now supports, run a normal upsert (step 6 of detection).
Why a hidden state instead of delete. Pattern_id is content-addressable. If we delete a pattern at count 2 and a future entry brings it back to 3, the new INSERT collides with no row. Keeping the row hidden makes the upsert idempotent.
| Dimension | Assessment |
|---|---|
| Complexity | Low — five primitives, one detector pass, deterministic IDs |
| Cost | Trivial at v1 scale (≤500 entries) |
| Demo legibility | High — every callout is sourced, every claim is countable |
| Spec-drift risk | Low — every locked decision in concept-locked.md/PRD.md/AGENTS.md is preserved |
| Team familiarity | Plain Kotlin; no new deps |
Pros: Sourced by construction. Idempotent across re-eval and cold-start sweep. Easy to test (ADR-002 Q4-style fixture suite extends naturally).
Cons: Bounded primitives. Doesn't catch patterns the five primitives can't express. (Mitigation: that's ../backlog.md.)
Compute embedding clusters over entries (using the STT-E-contingent EmbeddingGemma vector layer), surface clusters as patterns.
| Dimension | Assessment |
|---|---|
| Complexity | High — clustering algorithm choice, threshold tuning, cluster naming |
| Cost | Vector index recompute per detection run |
| Demo legibility | Lower — "this cluster" is harder to source than "this tag pair appeared 4 times" |
| Spec-drift risk | High — STT-E-conditional means patterns disappear if EmbeddingGemma is cut |
| Team familiarity | New territory mid-build |
Pros: Catches semantic-similarity patterns even without exact tag overlap. Cons: Pattern detection becomes STT-E-conditional, which fights the locked spec (patterns are P0; embeddings are STT-E-conditional). AGENTS.md guardrail 12 is harder to satisfy from a cluster than from a count.
After every 10 entries, send the full corpus to Gemma 4 and ask "what patterns do you see?"
| Dimension | Assessment |
|---|---|
| Complexity | Medium |
| Cost | Latency + battery — full-corpus prompt every 10 entries |
| Demo legibility | Inconsistent — model output varies run-to-run |
| Spec-drift risk | High — non-deterministic IDs break ADR-002's recurrence_link predicate |
| Team familiarity | OK |
Pros: Could find patterns the five primitives miss. Cons: Non-deterministic. Sources are model-narrated, not engine-counted (AGENTS.md guardrail 12 violated). Re-eval doesn't compose.
The dominant trade-off is explainability vs. expressive coverage. Option A is narrow but every claim is bulletproof. Option B/C might catch something Option A misses, but at the cost of either (1) STT-E conditionality or (2) AGENTS.md guardrail 12 violations.
For a 17-day build aimed at the demo and the privacy/sourced-claims story, narrow-but-bulletproof wins on both axes. The five primitives directly cover every callout example already written in concept-locked.md, design-guidelines.md, and ux-copy.md — there is no demo beat we lose by going narrow.
The other significant trade-off is mark-resolved sticky vs. auto-reopen. Sticky respects user agency and the "anti-pushy brand" rule. Auto-reopen is more "smart-app" but exactly the kind of feature the user dismissed for a reason. Sticky is the on-brand answer.
Easier:
- ADR-002's
recurrence_linkresolver predicate has a stable, testable definition. - Pattern detection has a unit-testable surface (synthetic entry sets → expected pattern IDs).
- The pattern engine output (sourced counts) drops cleanly into the persona-flavored observation generation prompt from ADR-002 §3.
- Re-eval composes — recomputing pattern matches on a single entry is cheap.
Harder:
- The five primitives are the ceiling for v1 patterns. If a demo scenario surfaces a needed sixth, this ADR gets superseded, not patched.
- Pattern title generation requires one short model call per new active pattern. Battery cost is small (titles cap at ≤24 chars) but counts against the per-entry budget. ADR-002's foreground/observation prompts already account for this.
- ObjectBox migration from the v1 schema if a future version adds incremental aggregates or per-pattern cooldown.
Revisit when:
- Entry counts cross ~1000 and full-recompute every 10 entries starts costing noticeable battery → introduce incremental aggregates.
- A v1.5 demo scenario actually needs tag triples or learned-cluster patterns → write a successor ADR; don't patch this one.
- User feedback shows mark-resolved is too sticky → add
auto-reopen-on-fresh-evidencesemantics (already noted in../backlog.md?).
Story 3.5's implementation initially read "≥2 distinct contexts" as ≥2 distinct
template_label values across the supporting set. That narrowed legitimate corpus shapes
out of the pattern engine — entries with templateLabel = null (the common case before
extraction has stabilized) were rejected wholesale, and a stretch of four aftermath-only
entries with the same vocab token collapsed to a context-count of 1.
The original ADR text "the diversity requirement so a single long sentence using 'tired'
four times does not become a pattern" describes the within-entry case. vocabTokensFor
returns a Set per entry, so each entry contributes once per token by construction — the
≥4 distinct-entry threshold already enforces the diversity guard.
Operational definition (v1): "≥N entries with ≥2 distinct contexts" reduces to ≥N
distinct entries containing the stemmed token. No additional template-label diversity
requirement. The VOCAB_MIN_CONTEXTS constant is removed; VOCAB_THRESHOLD = 4 is the only
gate.
Step 6's UPDATE branches:
active→ refreshessupportingEntryIds,lastSeenTimestamp, andlatestCalloutText.snoozed/dismissed/resolved→ refreshessupportingEntryIdsandlastSeenTimestampsilently.latestCalloutTextis not rewritten.
This preserves the user-facing string from the moment the row left ACTIVE. A v1.5 un-dismiss or un-snooze surface will show what the user last saw, not arbitrary later evidence.
The "lowercase, kebab-case, sorted" wording under §"pattern_id generation" is normative.
Implementation routes all label / tag / topic inputs through a single TagNormalize.kebab
helper that lowercases, replaces whitespace + underscores with hyphens, collapses repeats,
and trims edge hyphens. The same helper is called in the signature builder, the detector's
grouping keys, and the matcher's compare path so the hash inputs cannot drift between
subsystems.
§"Cooldown (callout-side only, global)" reads "After a callout fires on entry E, suppress callouts on the next 3 entries even when active patterns match." Operational reading: the window is wall-clock-by-entry — every entry committed during the window decrements the counter, regardless of whether that entry would have matched a pattern.
Implementation: PatternDetectionOrchestrator.onEntryCommitted consumes one slot at the
top of every call when the cooldown is active, then short-circuits. Only when the cooldown
is permitted does callout selection run. A blank latestCalloutText on a matched pattern
logs a warning and returns null without firing — it does not start a fresh window.
Trade-off rationale: a streak of unrelated entries between two matches can expire the window invisibly, which means two semantically-adjacent callouts can land back-to-back if the user logged unrelated entries between them. To the user that reads as appropriate spacing (other entries did go by); to the engine it's the simpler invariant — one rule, no carve-out, deterministic wall-clock pacing. The "anti-pushy brand" rule is satisfied either way because the perceived nag-rate is what the user types between, not what the counter says internally.
§Lifecycle & state transitions and §Mark-resolved is sticky for the demo are superseded
for v1 forward by docs/spec-pattern-action-buttons.md and docs/ux-copy.md §"Pattern List".
What changes:
- User-facing actions on a pattern collapse from three (
Dismiss/Snooze 7 days/Mark resolved) to two (Skip/Drop). TheMark resolvedaffordance is removed entirely — a pattern closing should be earned by behavioral evidence, not self-reported. That mirrors the core product framing: Vestige observes, it does not validate. PatternStateenum renames:RESOLVED→CLOSED,DISMISSED→DROPPED. The newCLOSEDstate is model-detected only — no user transition reaches it in v1. The staleness check that lands a pattern inCLOSEDis deferred to v1.5 (backlog.md§pattern-auto-close). v1 ships with theCLOSEDenum value reserved but unreachable so the v1.5 transition is a backfill plus check, not an ObjectBox migration.- New field
PatternEntity.snoozedUntil: Long?(epoch ms; null when not snoozed). Cold- start sweep transitionsSNOOZED → ACTIVEwhensnoozedUntilhas elapsed; no WorkManager job (per spec §P0.4). - Snackbar copy:
Dropped.andSkipped.retainUndo. Model-detectedClosedis silent — no snackbar, visible on next list load. - Section headers:
ACTIVE/SKIPPED · ON HOLD/CLOSED · DONE/DROPPEDperux-copy.md§"Pattern List / Section headers". The Mist-eraRESOLVED · FADEDandDISMISSEDlabels retire withMark resolved.
What does not change:
- Detection algorithm (five primitives, 90-day window, ObjectBox persistence).
- Callout cooldown semantics (Addendum 2026-05-11 still applies).
- Snoozed-on-stale-evidence rule (the snooze wake-up still requires the pattern to still meet threshold; spec §P0.4 honors this implicitly via the detection re-emerge path).
- Re-eval interaction with
below_threshold.
Story 4.8 carries the implementation. Story 3.8's markResolved API path and the
PatternAction.MARKED_RESOLVED enum value retire with that story; both stay live in v1
until Story 4.8 lands so existing shipped code keeps compiling.
Addendum (2026-05-13b) — Restart action; non-active states are reversible; snooze window preserved on undo
Refinement on top of the prior 2026-05-13 Addendum. The two-action user surface
(Skip / Drop) is extended by a third user-driven control, Restart, on any
non-active visible state. See docs/spec-pattern-action-buttons.md §P0.3 for the
acceptance criteria.
What changes:
- User-facing actions become
Skip+Drop(onACTIVE) andRestart(onSNOOZED/DISMISSED/RESOLVED).MARKED_RESOLVED/Donestays system-only (pattern-auto-close, v1.5) — the prior addendum's "no user transition reaches CLOSED in v1" rule still holds for that specific path. DISMISSEDandRESOLVEDare no longer terminal in the original "no transition out in v1" sense from§Lifecycle & state transitions. The persistence shape is unchanged — only the set of legal next states expands. The ObjectBox schema does not move.Restartundo restores the exact pre-restart snapshot, including the originalsnoozedUntilwhen the prior state wasSNOOZED. Previously the undo path resetsnoozedUntiltonull, which would have left the row inSNOOZEDwith no expiry — a silent foot-gun (the cold-start sweep would never wake it).PatternRepo.restartnow takes apreviousSnoozedUntil: Long?andforceTo(...)preserves it; arequire(snoozedUntilMs != null)precondition rejects a malformed snooze restore.- Snackbar copy adds
Pattern is back.(with Undo) for the Restart path.Skipped.andDropped.retain their copy from the prior addendum.
What does not change:
- Detection algorithm, callout cooldown, snoozed-on-stale-evidence rule, re-eval with
below_threshold— all carried forward from the prior addenda. - Story 3.8's
markResolvedAPI path and thePatternAction.MARKED_RESOLVEDenum value still retire with Story 4.8, per the prior addendum; Restart adds to the user surface, it does not change the retirement plan for the resolved-write path.
Companion to the ADR-002 (2026-05-15) personality + observation-depth addendum. The same
audit surfaced two structural gaps on the pattern-detection side. Both are queued for
post-Phase-4 — Scoreboard rebuild lands first. Neither alters the five primitives, the
content-addressable pattern_id rule, the matching predicate, or any persisted shape
recorded above.
time_of_day_cluster ships in Phase 2's tail, ahead of the Phase-3 pattern-engine work.
The user-visible cliff between Phase 2 (per-entry observations only) and Phase 3 (cross-
entry pattern callouts) is the single biggest gap between what the spec promises and what
the user sees. time_of_day_cluster is the cheapest of the five primitives to implement
in isolation — one signature (("goblin", null)), scan-once over the last 30 days, no tag
enumeration, no commitment-topic enumeration, no vocab-token frequency table.
Implementation lands in :core-storage per §"Detection algorithm (run every 10 entries)"
step 6 with the rest of the five-primitive detector stubbed (emptyList() returns from each
of the other four enumerators). PatternDetectionOrchestrator already carries the cooldown
machinery + tests; wiring time_of_day_cluster through it is the integration step. Result:
the Goblin Hours pattern callout becomes the first cross-entry observation users ever see —
turns "shallow per-recording response" into "I see a pattern after my third 3am entry"
without waiting for Phase 3's full pattern-engine landing.
The §"Detection cost" calculus is unchanged (one signature, scan-once is bounded by entry count, not by the other primitives' enumerator costs). The §"Pattern title generation" short-model-call path runs only when this single primitive crosses threshold, so the Phase-2 title-generation cost is bounded to one pattern-title model call ever, not per detection run.
When Phase 3 lands, the other four enumerators wire in alongside; time_of_day_cluster's
implementation does not need a rewrite. The §"ObjectBox Pattern entity" schema and the
state machine are the contract this pre-work commits to.
Verdict chips in entry detail — the convergence resolver becomes a visible product surface.
§"pattern_id generation" + ADR-002 §"Convergence Resolver Contract / Resolution rules"
write per-field verdicts (consensus / candidate / ambiguous / consensus_with_conflict)
that today live in storage and never reach the user except indirectly through the
deterministic-vs-model observation-generation routing. The Reading section per
concept-locked.md §Re-eval is described as showing per-lens output collapsed; surface the
verdict on each canonical field as a small chip in the same view (consensus /
candidate / ambiguous / consensus_with_conflict).
The data exists in ObjectBox today (Story 2.8 ticked, ResolvedField.verdict is persisted
per Story 2.12). The work is a Phase-4 read path, not a Phase-3 write path. This is the
demo beat that earns the multi-lens architecture visually — judges see "the model
disagreed with itself here" without needing the technical-walkthrough explanation. ADR-002's
sixth-addendum rubric verified consensus_with_conflict reaches A4 and B2 on the STT-D
corpus deterministically across three runs; those entries become the demo storyboard
material when this surfacing lands.
Surfacing belongs to Phase 4 (after the Scoreboard rebuild stabilizes per ADR-011) and the
chip styling is owned by design-guidelines.md. Recording the data ↔ surface contract
here so the Phase-4 work has the spec it needs.
Pattern-callout cooldown stays as written; deterministic-observation cooldown generalizes from it.
§"Cooldown (callout-side only, global)" is unchanged. ADR-002 (2026-05-15) introduces a
parallel DeterministicObservationCooldown keyed by ObservationEvidence enum value with
the same 3-entry window and same global semantics as defined here. The two cooldowns
coexist — one gates pattern-callout appends (this ADR), the other gates deterministic
observation kinds (ADR-002). The §"Why global, not per-pattern" rationale generalizes to
the observation cooldown: per-kind cooldown lets unrelated evidence kinds fire freely while
suppressing the kind that already fired, which is exactly the noise the user-facing
deterministic-observation case needs suppressed. Identical knob shape, different keying
domain.
What does not change.
- The five pattern primitives, their thresholds, the matching predicate, the
content-addressable
pattern_idrule, thePatternObjectBox entity, the lifecycle state machine (with the 2026-05-13 + 2026-05-13b user-action revisions), the global pattern-callout cooldown, the Re-evalbelow_thresholdinteraction. - The "no machine-learning-shaped detection in v1" rationale from §"Pattern primitives". Surfacing verdicts as chips is reading existing engine output, not adding learned detection.
- The
pattern_idcontent-addressable rule. Pre-shippingtime_of_day_clusterahead of the other four primitives does not affect its hash inputs (the signature is("goblin", null), defined in §"Pattern primitives" v1 row).
Implementation queued for post-Phase-4. Story file gets entries per item; tracking lives in stories, work-decision rationale lives here.
ADR-014 moved pattern analysis into a background lane and made the old threshold trigger stale. This addendum extends the deterministic detector so that lane has calendar-shaped evidence before any model interpretation runs.
What changes:
PatternKind.TEMPORAL_RELATIVEis added as a deterministic primitive.- The detector emits a
weekday_time_blocksignature when at least three distinct local dates share the same weekday and broad local time block (morning,afternoon,evening,night) inside the 90-day window. - The detector emits a
month_startsignature when at least three distinct months have a completed entry on day 1 inside the 90-day window. - Matching uses the same local timezone rules as detection, so per-entry callout selection can support an existing temporal pattern without re-running the detector.
What does not change:
- Deterministic signatures remain content-addressable.
- Supporting entries remain the source of truth; model text may name or summarize the pattern later, but it may not invent evidence.
- Existing primitives, lifecycle states, cooldown behavior, and ObjectBox fields stay as written.
Rationale:
- A same-weekday/time-block cohort covers cases like "Tuesday afternoon for the previous two Tuesdays" without scanning arbitrary neighbors.
- A month-start cohort covers the recurring calendar-edge case directly.
- Requiring distinct dates/months prevents three entries dumped on one afternoon or one first-of-month from pretending to be recurrence.
The §"Why content-addressable" bullet about "if we restored from markdown, the ObjectBox cache would rebuild with the same IDs" is now historical. ADR-017 inverts the storage SOT: ObjectBox is authoritative; markdown is generated at export only. The content-addressable guarantee still holds because pattern_id is deterministically derived from the signature — the determinism does not depend on the storage direction.