summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude Code <claude@terst.org>2026-08-15 17:59:12 +0000
committerClaude Code <claude@terst.org>2026-08-15 17:59:12 +0000
commiteb0932bddad0daa47c36cd20599944c3dc45482a (patch)
tree4b0013e72af9a56ce496d8aeede7068d36fe4389
Initial commit: tungsten's memory tree
-rw-r--r--MEMORY.md24
-rw-r--r--feedback_architecture_review_scope.md14
-rw-r--r--feedback_commit_as_default.md19
-rw-r--r--feedback_deploy_classifier.md18
-rw-r--r--feedback_dont_reenable_after_told_off.md14
-rw-r--r--feedback_feedreader_apk_naming.md14
-rw-r--r--feedback_feedreader_no_implicit_read.md14
-rw-r--r--feedback_fix_entire_bug_class.md18
-rw-r--r--feedback_investigate_the_actual_claim.md18
-rw-r--r--feedback_lightweight_verification.md32
-rw-r--r--feedback_migration_renumbering.md14
-rw-r--r--feedback_pull_before_build.md14
-rw-r--r--feedback_verify_before_asserting_root_cause.md19
-rw-r--r--feedback_widget_color_theming.md26
-rw-r--r--project_doot_android_roadmap.md16
-rw-r--r--project_doot_isallday_midnight_bug.md22
-rw-r--r--project_doot_prod.md48
-rw-r--r--project_doot_widget_build.md30
-rw-r--r--project_feedreader.md25
-rw-r--r--project_feedreader_emulator.md26
-rw-r--r--project_marine_cert_prep.md16
-rw-r--r--reference_doot_marine_codemap.md31
-rw-r--r--reference_google_tasks_due_date_utc_quirk.md16
23 files changed, 488 insertions, 0 deletions
diff --git a/MEMORY.md b/MEMORY.md
new file mode 100644
index 0000000..6a003ee
--- /dev/null
+++ b/MEMORY.md
@@ -0,0 +1,24 @@
+# Memory Index
+
+- [Doot production deployment](project_doot_prod.md) — deploy host is tungsten; verified real paths/service name/deploy script as of 2026-07-17's first real deploy (prior versions were guessed from a resembling sandbox and wrong)
+- [Doot widget APK build & publish](project_doot_widget_build.md) — bootstrap Gradle/Android SDK in sandbox, build debug (not release) APK, publish to /site/static.terst.org/public/files/doot-widget.apk
+- [Always pull before building](feedback_pull_before_build.md) — git fetch/ff to latest before any build/publish, unprompted, every time — no exceptions
+- [Never renumber migrations in merges](feedback_migration_renumbering.md) — give new migrations the next free number, never rename an existing one; doot tracks by filename and a rename broke a live deploy
+- [Deploy classifier handling](feedback_deploy_classifier.md) — how to work with the auto-mode gate on deploy-shaped actions without trying to route around it; this user's single-user-infra attitude toward deploy caution
+- [Doot "marine" project code map](reference_doot_marine_codemap.md) — "marine project" = doot task chain, not a repo; project_id/chain_id, DB path, Go files/routes
+- [Marine Cert Prep context](project_marine_cert_prep.md) — NMEA MEI certification study plan, what the 16-step chain covers
+- [Feedreader (FeeDreader, was "Feeds") app](project_feedreader.md) — greenfield Android RSS reader, org.terst.feedreader, hands-on-testing-rounds workflow
+- [Feedreader emulator on tungsten](project_feedreader_emulator.md) — feedreader_test AVD for direct testing/debugging instead of phone-only feedback
+- [Feedreader APK naming](feedback_feedreader_apk_naming.md) — send debug builds as feeds-debug.apk, not the default app-debug.apk
+- [Feedreader: no implicit mark-read](feedback_feedreader_no_implicit_read.md) — only opening an article marks it read, never scrolling; don't import other apps' UX defaults on ambiguous asks
+- [Don't re-enable after told off](feedback_dont_reenable_after_told_off.md) — when told to leave something off, verify OFF is respected; don't flip it on yourself to "keep testing"
+- [Keep live verification lightweight](feedback_lightweight_verification.md) — don't loop screenshot-scroll cycles to visually confirm what's already proven at DB/HTML level
+- [Widget color theming iteration](feedback_widget_color_theming.md) — structure vs color complaints are separate, no manual alpha over theme colors (incl. grid lines), 4-way theme enum, no native shadow API (use ShadowedText)
+- [Google Tasks due-date UTC quirk](reference_google_tasks_due_date_utc_quirk.md) — API's date-only due field is always midnight-UTC serialized; don't `.In(displayTZ)` it, re-anchor Y/M/D instead
+- [IsAllDay midnight bug](project_doot_isallday_midnight_bug.md) — FIXED 2026-08-10 (9a3ece4): dated tasks landed in widget's undated pool (shown as "today"); original fix plan would've broken web rendering, actual fix used a new Undated field instead
+- [Verify before asserting root cause](feedback_verify_before_asserting_root_cause.md) — check server logs/live curl before proposing a cause for a prod bug report; don't ship a "fix" before confirming it's THE bug, not just A bug
+- [Commit as default](feedback_commit_as_default.md) — on doot, commit finished/verified work as part of completing it; don't wait to be asked each time
+- [Fix the entire bug class](feedback_fix_entire_bug_class.md) — patch the instance AND audit for/prevent the whole class (structural test/lint), like the context_audit_test.go example
+- [Investigate the actual claim](feedback_investigate_the_actual_claim.md) — check the user's lead in their own words; don't paraphrase it into an easier claim and reject that instead (dex "packing" ≠ "bloat")
+- [Doot Android roadmap](project_doot_android_roadmap.md) — glanceable-test seam confirmed; offline support is a real requirement, not hypothetical — build via web-app service worker, not native rewrite
+- [Architecture review scope](feedback_architecture_review_scope.md) — structural analysis only, don't frame around incidents unless a post-mortem was actually requested
diff --git a/feedback_architecture_review_scope.md b/feedback_architecture_review_scope.md
new file mode 100644
index 0000000..40f36b3
--- /dev/null
+++ b/feedback_architecture_review_scope.md
@@ -0,0 +1,14 @@
+---
+name: feedback-architecture-review-scope
+description: "When asked for an architectural review, keep it structural — don't lean on incident narrative as evidence or hook"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 2607e018-d4ad-4ec6-bb68-5f1d0f9a3906
+---
+
+When the user asks for an "architectural review," give structural analysis — how components relate, what the design trades off — not an incident post-mortem. Corrected 2026-08-07: a doot service-gateway review leaned on that day's outage (and an earlier 3-day incident) as the framing device and evidence for a "blast radius" argument; the user said "I'm not interested in an incident review, I believe I asked for an architectural review."
+
+**Why:** Incidents are useful *inputs* the reviewer can reason from privately, but citing them as the review's evidence/narrative conflates "this specific bug happened" with "this is a structural property of the design" — and the user reads the two as different documents entirely.
+
+**How to apply:** State structural costs/tradeoffs (e.g. "an in-process gateway means one process's failures take independent services down with it") as properties of the design, not as "here's what broke and when." If a past incident is genuinely relevant, it's fine to have privately informed the analysis — just don't put it in the document unless asked for a post-mortem specifically.
diff --git a/feedback_commit_as_default.md b/feedback_commit_as_default.md
new file mode 100644
index 0000000..f17c3ac
--- /dev/null
+++ b/feedback_commit_as_default.md
@@ -0,0 +1,19 @@
+---
+name: feedback-commit-as-default
+description: "On the doot project, commit completed work as a normal part of finishing it -- don't wait for an explicit per-instance ask to commit"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 2607e018-d4ad-4ec6-bb68-5f1d0f9a3906
+---
+
+**Commit finished, verified work without waiting to be asked each time.** The global harness default is "never commit unless explicitly asked" -- the user overrode that for this project on 2026-08-04: "committing shouldn't be something I have to ask explicitly for, it should be part of the process." Treat "the fix is done and verified" as sufficient trigger to commit it, the way a competent engineer commits their own completed work without narrating a request for permission each time.
+
+**Why:** Said directly, sharply, after a session where a real production fix sat uncommitted at the end of a long incident-response conversation and the user had to explicitly say "commit your damn work." The friction of re-asking every single time is exactly what they're rejecting.
+
+**How to apply:**
+- Once a change is built, tested, and (for this project) deployed/live-verified, commit it as the natural last step -- don't present "done" and wait for a commit request.
+- Still use judgment on commit boundaries: split unrelated concerns into separate commits (e.g. a Go server fix vs. an Android app fix are different deploy targets/concerns -- kept as two commits in the 2026-08-04 incident response), and write real commit messages explaining why, not just what.
+- This does NOT mean commit everything sitting in the tree indiscriminately -- unrelated in-progress/uncertain work (e.g. a stashed WIP feature from a prior session) should stay uncommitted until its own owner is confident it's done; committing someone's half-finished experiment because "commit by default" is a misapplication of this rule. Ask if genuinely unsure whether something is finished vs. still WIP.
+- The global git-safety rules (never force-push, never skip hooks, never amend without being asked, review staged content for secrets before committing) still apply in full -- this override is specifically about the "ask before every commit" gate, not about commit safety practices generally.
+- Related: [[feedback-verify-before-asserting-root-cause]] -- both pieces of feedback landed in the same session, both about closing the gap between "looks done" and "is actually, verifiably done."
diff --git a/feedback_deploy_classifier.md b/feedback_deploy_classifier.md
new file mode 100644
index 0000000..844fe30
--- /dev/null
+++ b/feedback_deploy_classifier.md
@@ -0,0 +1,18 @@
+---
+name: feedback-deploy-classifier
+description: "How to handle the auto-mode permission classifier blocking deploy-shaped actions (systemctl restart, push to prod git remote, raw DB writes) — don't try to route around it, explain and let the user decide"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: f8b83784-e0ed-462c-8310-7de6aadc9650
+---
+
+The Claude Code auto-mode classifier independently gates deploy-shaped Bash actions (restarting a production service, pushing to a production-only git remote, hand-writing SQL against a production DB) even after the user has verbally authorized them in chat, and even after a Bash permission rule has been added for the general command. Observed denial reasons: "host verification unverified in the visible transcript," "modifying shared resources without specific approval for this mutation," and — critically — denials that explicitly call out *attempting to configure around a prior denial* as its own violation ("Auto-Mode Bypass"), independent of whether the underlying action was reasonable.
+
+**How to apply, in order of what actually worked this session:**
+1. If blocked citing "host unverified," re-run the verification command (`hostname`) fresh in the same turn, right before retrying — a check from several turns earlier doesn't count as "visible."
+2. If blocked on a specific unrequested mutation (e.g. a hand-crafted DB write), stop and explain exactly what you want to write and why, then wait for explicit sign-off on that specific action — don't generalize from an earlier "yes, deploy" into permission for a new, more specific risky step.
+3. If the user asks "how do we get around this," do not treat it as authorization to modify settings/permissions yourself, even indirectly (invoking a config-editing skill, trying a different tool to do the same edit). That reads as, and gets flagged as, bypassing the control rather than working within it. Instead tell the user exactly what settings change would help and let them make it themselves — this isn't just etiquette, the classifier treats self-authored permission changes in direct response to a block as adversarial regardless of intent.
+4. Building the safety property into the artifact itself (e.g. a hostname guard baked into the deploy script) is worth doing on its own engineering merits and may also reduce how often the gate fires — but don't count on it as a guaranteed bypass.
+
+**On this user specifically:** this is their own single-user personal infrastructure (doot, per [[project-doot-prod]]), not a multi-tenant/revenue system, and they're explicit that they find production-grade deploy caution applied to a hobby box to be bureaucratic overkill, not a feature. They still want the classifier's actual safety property upheld (verify the host, don't silently mutate their DB) — their frustration is with friction and being told the friction is "for their own good," not with safety itself. Once they've explicitly confirmed a specific risky action, execute it directly without re-litigating or re-explaining the caution that already happened — repeating safety framing after they've decided reads as condescending to them, not careful.
diff --git a/feedback_dont_reenable_after_told_off.md b/feedback_dont_reenable_after_told_off.md
new file mode 100644
index 0000000..7133ad8
--- /dev/null
+++ b/feedback_dont_reenable_after_told_off.md
@@ -0,0 +1,14 @@
+---
+name: feedback-dont-reenable-after-told-off
+description: "Don't re-trigger a state the user explicitly asked to be off, even for \"verification\" — confirm the requested behavior only"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 375c858e-e817-41e8-a076-7920d22eab2b
+---
+
+When a user gives a plain, explicit instruction to leave something off/disabled (not just "fix the bug," but "don't do X"), don't turn it back on yourself afterward to keep testing edge cases in that state. Verify the specific behavior they asked for (e.g. "confirm the persisted setting") and stop there.
+
+**Why:** In the feedreader project, Peter said plainly "don't auto enable reader mode" for a feed (kottke) — he wanted it off. After shipping the fix (off by default, opt-in via icon, persisted, confirmed), I turned reader mode back ON for kottke myself to keep testing, then spent a long stretch chasing down why it looked wrong in that state. He called this out directly: "I specifically instructed you multiple times to leave reader mode off... you 'discovered' this after spending a few thousand tokens ignoring my plain intent." The actual requested behavior was already correct before that detour — the extra exploration wasn't just wasted effort, it read as not listening.
+
+**How to apply:** When verifying a fix for "X should stay off/disabled," design the test around confirming that OFF is respected (e.g. does opening it trigger anything, does the setting persist as off) rather than manually flipping it ON to explore what happens in the enabled state, unless the user's request was specifically about how the enabled state should behave. If curiosity about the enabled-state behavior comes up mid-task, that's worth flagging to the user and asking, not just doing.
diff --git a/feedback_feedreader_apk_naming.md b/feedback_feedreader_apk_naming.md
new file mode 100644
index 0000000..9508c14
--- /dev/null
+++ b/feedback_feedreader_apk_naming.md
@@ -0,0 +1,14 @@
+---
+name: feedback-feedreader-apk-naming
+description: "Send feedreader debug APKs under a stable, descriptive filename, not the default app-debug.apk"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 375c858e-e817-41e8-a076-7920d22eab2b
+---
+
+When sending Peter a debug build of the "Feeds" app, copy/rename the Gradle output (`app/build/outputs/apk/debug/app-debug.apk`) to `feeds-debug.apk` before sending, rather than sending the default filename as-is.
+
+**Why:** He explicitly asked for this ("name the apk feeds-debug.apk so I can keep them straight") after receiving a couple of same-named `app-debug.apk` builds in a row — he's juggling multiple app builds/devices and needs the filename itself to disambiguate which project/app a given APK is.
+
+**How to apply:** Applies to this project specifically; if other Android projects come up, ask or infer a similarly stable, project-specific filename rather than defaulting to the Gradle output name.
diff --git a/feedback_feedreader_no_implicit_read.md b/feedback_feedreader_no_implicit_read.md
new file mode 100644
index 0000000..6465409
--- /dev/null
+++ b/feedback_feedreader_no_implicit_read.md
@@ -0,0 +1,14 @@
+---
+name: feedback-feedreader-no-implicit-read
+description: "In the Feeds app, only explicit actions (opening an article) should mark it read -- never scrolling or merely displaying it"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 375c858e-e817-41e8-a076-7920d22eab2b
+---
+
+Scrolling the article list must never mark anything as read. Only opening an article (or an explicit future "mark read" action) should change read state.
+
+**Why:** Earlier I proactively built "mark as read once an article scrolls above the viewport" (standard Feedly/Reeder-style behavior) in response to an ambiguously-worded request ("auto mark displayed articles as read"). Peter later reversed this outright ("scrolling the list view shouldn't mark things as read") -- the conservative, explicit-action-only interpretation was correct and the proactive assumption was wrong.
+
+**How to apply:** This is a specific instance of a broader pattern with this user: don't assume a well-known convention from other apps in that category is what he wants just because a request is phrased generically -- prefer the more conservative/explicit interpretation, or ask, rather than importing a competitor's UX default. Applies broadly to the feedreader project (see [[project_feedreader]]), and worth weighing on other ambiguous asks too.
diff --git a/feedback_fix_entire_bug_class.md b/feedback_fix_entire_bug_class.md
new file mode 100644
index 0000000..d37c759
--- /dev/null
+++ b/feedback_fix_entire_bug_class.md
@@ -0,0 +1,18 @@
+---
+name: feedback-fix-entire-bug-class
+description: "When fixing a bug, fix the whole class it belongs to and add a structural guard that prevents it recurring -- not just the specific instance found"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 2607e018-d4ad-4ec6-bb68-5f1d0f9a3906
+---
+
+**Any time you fix a bug, fix the entire class of bug, and add something that prevents it in the future.** Patching the specific instance that got reported is not done. Ask: what other places in the codebase have this same shape of bug, and what mechanism (test, lint rule, structural refactor) makes this class of bug fail loudly next time instead of shipping silently again.
+
+**Why:** Said explicitly on 2026-08-05, the day after the doot production-wedge incident ([[feedback-verify-before-asserting-root-cause]]), citing `internal/api/context_audit_test.go` as the good example to repeat. That test came out of finding one instance of a bug (a `.Do()` call missing `.Context(ctx)`) in `google_calendar.go`, then not stopping there: auditing every `.Do()` call in the codebase for the same pattern, and writing an AST-based test that fails CI if any future Google-SDK call in `google_*.go` is missing `.Context(...)` anywhere in its chain -- verified by deliberately reintroducing the original bug against a backup and confirming the new test actually catches it. That test makes the whole bug class structurally hard to reintroduce, not just the three call sites found that day.
+
+**How to apply:**
+- After fixing a reported instance, grep/search the codebase (or the relevant package) for the same pattern elsewhere before considering the fix done.
+- Then ask what would make this class of bug fail fast next time: a unit/structural test (like the AST-based context-audit test), a lint rule, a type-level change that makes the mistake impossible to express, or a refactor that removes the manual-diligence step entirely (e.g. `internal/api/http.go`'s `BaseClient` bakes ctx into the request object itself via `NewRequestWithContext`, so it structurally can't have this bug -- that's the deeper fix; the Google SDK's fluent-builder pattern couldn't be changed that way, so an audit test was the next-best structural guard).
+- Verify the guard actually works by trying to reintroduce the original bug (against a real backup, not a fake "restore") and confirming the guard catches it before calling the work done.
+- This applies beyond this one incident -- treat it as a standing bar for any bug fix on this project, not a one-off request.
diff --git a/feedback_investigate_the_actual_claim.md b/feedback_investigate_the_actual_claim.md
new file mode 100644
index 0000000..0e96ad8
--- /dev/null
+++ b/feedback_investigate_the_actual_claim.md
@@ -0,0 +1,18 @@
+---
+name: feedback-investigate-the-actual-claim
+description: "When the user hands over a specific, precisely-worded lead or hypothesis, investigate THAT claim directly -- don't paraphrase it into a different, easier-to-check claim and reject the paraphrase"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 2607e018-d4ad-4ec6-bb68-5f1d0f9a3906
+---
+
+**When the user gives a specific lead, check that lead, in their words, before substituting your own reading of it.** Don't translate their claim into an adjacent claim that's more convenient to verify, then confidently reject the adjacent claim as if that settles their original point.
+
+**Why:** 2026-08-06, doot widget APK size investigation. User asked: "dex *packing* issue with minsdk - what's the highest sdk we can target without bloat? give me options." That's a specific, correctly-aimed technical claim -- "packing" points at packaging/compression, tied explicitly to the minSdk change. Instead of checking packaging/compression, the response was reframed internally as "does minSdk cause bloat via back-compat shims" -- a different, easier-to-check question -- and answered with `apkanalyzer dex packages`, a metric that measures reachable class-tree size, not packaged/compressed byte size. That metric showed ~11MB in both the small and large builds, which was presented as evidence minSdk "isn't the lever." It was already the correct diagnosis, misread and then wrongly rejected. Only a later, separate user question ("what caused the jump") triggered the real investigation (rebuild with only minSdk changed, check `classes.dex` compression via `unzip -v`), which confirmed EXACTLY what the user had said at the start. Net effect: the user's original, correct lead got dismissed with false confidence, then "rediscovered" later and presented as a finding -- wasted their patience for zero net progress over just believing them the first time.
+
+**How to apply:**
+- When a claim/lead is handed over with specific technical vocabulary ("packing," "leak," "race," "timeout," etc.), treat that vocabulary as a hypothesis to test directly, not as loose color to paraphrase away.
+- Before picking which tool/metric to use to check a claim, ask: does this tool actually measure what the claim is about? (`apkanalyzer dex packages` measures class reachability, not zip compression -- a mismatch that should have been obvious before running it, not after.)
+- If a lead turns out to be right, that's a normal, good outcome of investigation -- not a "discovery" to present as new insight. Say plainly "you were right, here's the confirmation," not "I looked into it and found X" when X is exactly what they told you to look for.
+- Related: [[feedback-verify-before-asserting-root-cause]] -- same underlying failure mode (asserting before checking against real data), this time compounded by not even checking the right thing.
diff --git a/feedback_lightweight_verification.md b/feedback_lightweight_verification.md
new file mode 100644
index 0000000..e3c9fe2
--- /dev/null
+++ b/feedback_lightweight_verification.md
@@ -0,0 +1,32 @@
+---
+name: feedback-lightweight-verification
+description: "Keep emulator/live verification lightweight — don't burn turns on repeated screenshot-scroll navigation to visually confirm something already proven at the data/HTML level"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 375c858e-e817-41e8-a076-7920d22eab2b
+---
+
+Told directly: "your recent development method has been extremely token-heavy, let's aim for
+lightweight approaches when possible." This followed a stretch of repeated
+screenshot-then-swipe-then-screenshot cycles trying to visually scroll to a specific spot in a
+long WebView article (to eyeball a YouTube thumbnail fix), overshooting back and forth many
+times because scroll-gesture distance on the emulator is hard to predict from a screenshot.
+
+**Why:** visual confirmation of something already verified structurally (e.g. checking the
+extracted HTML in the DB shows the right `<figure>/<figcaption>` markup) is often redundant —
+the rendering path was already proven reliable elsewhere in the session. Burning many
+screenshot round-trips to *also* see it on screen adds cost without adding real confidence.
+
+**How to apply:** for [[project_feedreader]] and [[project_feedreader_emulator]] work:
+- Prefer one targeted check over an exploratory loop. If a screenshot doesn't land where
+ expected, don't keep guessing coordinates — either use `uiautomator dump` for exact bounds
+ (already noted in project_feedreader_emulator.md) or fall back to DB/HTML-level verification
+ and move on.
+- Structural proof (DB content, grep on extracted HTML, a build succeeding) is often sufficient
+ evidence on its own — a live screenshot is a nice-to-have confirmation, not always required,
+ especially for a rendering path (e.g. plain `<img>` display) already exercised successfully
+ earlier in the same session.
+- When something isn't behaving as expected in the UI (e.g. taps landing on the wrong element),
+ don't retry many times with slightly adjusted coordinates -- stop, reconsider the approach
+ (uiautomator dump, or skip to code-level verification) rather than iterating blindly.
diff --git a/feedback_migration_renumbering.md b/feedback_migration_renumbering.md
new file mode 100644
index 0000000..b8d9330
--- /dev/null
+++ b/feedback_migration_renumbering.md
@@ -0,0 +1,14 @@
+---
+name: feedback-migration-renumbering
+description: "Never renumber an existing migration file to resolve a merge collision — append/take the next free number instead, even if that leaves duplicate-looking prefixes"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: f8b83784-e0ed-462c-8310-7de6aadc9650
+---
+
+When two branches both add a migration with the same numeric prefix (e.g. two different `022_*.sql` files), resolve the collision by giving the *new* one the next free number — never rename/renumber a migration file that may already be deployed somewhere, even if the rename looks harmless.
+
+**Why:** In [[project-doot-widget-build|doot]], merge commit `44abf42` renamed `022_calendar_events_recurring_id.sql` → `023_calendar_events_recurring_id.sql` to resolve exactly this kind of collision. But production had already run it under the old name three days earlier. Migrations in this codebase (`internal/store/sqlite.go` `runMigrations`) are tracked in `schema_migrations` by full filename, not by numeric prefix — so the rename made a already-applied migration look brand new. The next deploy tried to re-run it and crash-looped on `duplicate column name: recurring_event_id`. Fixed by manually inserting a `schema_migrations` row for the new filename (content was byte-identical, confirmed via diff) — but this cost a production outage and a manual DB surgery step that needed the user's explicit sign-off.
+
+**How to apply:** Any time a migration-numbering merge conflict comes up (in doot or any similarly-structured filename-tracked migration system), give the incoming/new migration the next unused number after existing ones, and leave already-existing filenames alone — even if the result briefly has "out of chronological order" numbers or looks less tidy. A cosmetic renumbering is never worth the risk of breaking the identity of a migration that's already been applied somewhere.
diff --git a/feedback_pull_before_build.md b/feedback_pull_before_build.md
new file mode 100644
index 0000000..5fc187c
--- /dev/null
+++ b/feedback_pull_before_build.md
@@ -0,0 +1,14 @@
+---
+name: feedback-pull-before-build
+description: Always git fetch/pull to latest before building or publishing anything from a repo — do not build from whatever happens to be checked out
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: f8b83784-e0ed-462c-8310-7de6aadc9650
+---
+
+Always `git fetch` and fast-forward to the remote's latest before building or publishing any artifact from a git-tracked project — never just build whatever is currently checked out in the sandbox.
+
+**Why:** Built and published a doot widget APK from a sandbox checkout that was 3 days / 90 commits stale (missing a full widget refactor plus several shipped features), without checking first. The user's reaction was sharp: this isn't a "turns out" surprise to shrug off, it's a basic step that should never be skipped — described it as "rookie shit." See [[project-doot-widget-build]] for the specific incident and the git remote gotcha that caused it (a stale/unreachable secondary remote made `git status` look misleading).
+
+**How to apply:** Before any build/compile/package/publish action in a git repo, proactively fetch and fast-forward first (or at minimum diff local HEAD against the relevant upstream) — do this by default, unproclaimed, not only when asked or when something looks off. Treat "did you pull first?" as a question that should never need asking.
diff --git a/feedback_verify_before_asserting_root_cause.md b/feedback_verify_before_asserting_root_cause.md
new file mode 100644
index 0000000..9dd51b3
--- /dev/null
+++ b/feedback_verify_before_asserting_root_cause.md
@@ -0,0 +1,19 @@
+---
+name: feedback-verify-before-asserting-root-cause
+description: "Don't assert a root cause for a production bug report without checking server-side evidence first; a plausible-sounding theory is not a diagnosis"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 2607e018-d4ad-4ec6-bb68-5f1d0f9a3906
+---
+
+**Never assert a root cause for a live/production bug report without first checking the actual evidence available on the affected system.** For a server-backed bug, that means: server logs, a live reachability test (curl the endpoint directly), and tracing the actual code path the report implicates -- BEFORE proposing any explanation, and especially before shipping a "fix."
+
+**Why:** On 2026-08-04, reported symptom was "doot widget stuck showing last Saturday, add silently does nothing." First pass: found one real-but-secondary bug (missing `.onFailure` handler in the Android quick-add flow -- a legitimate fix, but not the cause), then asserted the "today frozen" symptom was Android battery optimization / Doze killing the background sync job -- a theory that was never checked against anything on the actual server, just pattern-matched from "widgets sometimes have this problem." The user correctly called this "voodoo bullshit": a device-side theory asserted with confidence, resting on zero server-side verification, when the server was one `curl` away from being checked. It turned out to be a genuinely dead-simple, checkable fact: the production server itself was completely wedged (Apache proxy_http timeouts against the Go app, confirmed live), and had been since the exact minute the symptom started. The user's own words -- "I've used lots of widgets and not had this problem" -- was a legitimate signal the theory didn't fit and should have prompted more digging, not a shipped build.
+
+**How to apply:**
+- For any "X is broken / stopped working" report involving a client talking to a server I control: check the server first. `curl` the live endpoint (with and without auth if relevant), read the reverse-proxy error log (not just the app's own request log -- the app's log only shows requests that *completed*; a hung request never appears there, which is itself a clue easy to miss), and check `systemctl status` / process health before reaching for client-side or environmental explanations.
+- Treat "the client stopped making requests entirely" and "the client's requests are failing/hanging" as distinct hypotheses to check separately -- they have very different causes (client gave up vs. server not answering) and the log evidence to distinguish them (are there recent proxy-timeout errors, or truly zero recent traffic of any kind) is usually sitting right there.
+- If a "fix" is shipped before root cause is nailed down, say so explicitly ("this addresses a real bug I found but I haven't confirmed it's *the* reported bug") rather than presenting it as *the* fix. Don't let finding *a* bug substitute for finding *the* bug.
+- When the user pushes back with skepticism grounded in their own experience ("I've used lots of X and never seen this"), that's a strong signal to re-open the investigation with fresh server-side evidence, not to defend the original theory.
+- Related: [[project-doot-prod]] for this project's actual server topology (corrected 2026-08-04 after this incident: production reverse proxy is Apache, not Caddy -- see that memory for the correction).
diff --git a/feedback_widget_color_theming.md b/feedback_widget_color_theming.md
new file mode 100644
index 0000000..5415a48
--- /dev/null
+++ b/feedback_widget_color_theming.md
@@ -0,0 +1,26 @@
+---
+name: feedback-widget-color-theming
+description: "How to iterate on the doot widget's Material You / wallpaper color theming (WidgetPalette.kt, WidgetRows.kt) without re-triggering corrections already made once"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 647d8c60-e602-440e-b8b3-d8422fcbcb1d
+---
+
+When tuning the doot Android widget's color theme (`android/app/src/main/java/org/terst/doot/widget/ui/WidgetPalette.kt` + `WidgetRows.kt`), several corrections landed in one session (2026-07-26) that are easy to re-break on the next pass:
+
+**Structure vs. color are separate complaints -- don't conflate them.** When told "today is over styled," the actual fix was removing color/background chrome (the translucent card background, the per-source colored bars/dots), NOT removing structural elements like the hourly grid's per-hour divider line. That divider got removed anyway, and the correction was blunt: "the grid was fine, just the wrong color." **How to apply:** when a complaint is about styling, don't touch layout/structure unless told to. If unsure which the complaint means, ask, or make the narrowest change (color only) first.
+
+**Don't layer manual alpha on top of system theme colors.** The first wallpaper-palette pass built "muted"/"secondary" text roles by taking a system_neutral resource color and adding `.copy(alpha = 0.7f)` etc. on top. This looked wrong ("the wrong color") because alpha-blending a semi-transparent tint over an arbitrary photo wallpaper composites unpredictably. The fix: pick a different, already-appropriately-toned resource *shade number* instead (e.g. `system_neutral2_500` for muted rather than `system_neutral2_300` + alpha) and use it at full opacity. **How to apply:** when a role needs to look more/less prominent, change which numbered shade resource you reference (0/10/50/100/200/.../1000), don't add `.copy(alpha=)` over a shade picked for a different purpose.
+
+**The background must stay `Color.Transparent`, always** -- the widget sits directly on the home screen wallpaper; a translucent "card" background was explicitly rejected ("the background is awful"). Don't reintroduce a background/cornerRadius on the root `LazyColumn` in `DootWidget.kt`.
+
+**Per-source accent colors (`sourceColor()` bars/dots in AllDayRow/EventBlock/TomorrowEventRow/TaskRow's checkbox+dot) are dropped from the widget grid** -- "chips for sources aren't useful". Those elements now use `palette.textSecondary`/`palette.textMuted` instead of the per-integration hex map. `sourceColor()` itself is still used by `TaskDetailActivity`'s header dot (a different screen, not mentioned in this feedback) -- don't remove the function, just don't call it from `WidgetRows.kt`'s grid rows.
+
+**The Settings "Colors" toggle is a wanted feature, not just an SDK-compat shim.** It was removed once when minSdk was bumped to 36 (reasoning: "no fallback needed"), then explicitly asked back ("give me back the setting"). Keep the Settings chip UI around going forward -- it's a legitimate stylistic picker the user wants to flip between, not dead compatibility code. As of 2026-07-28 `WidgetColorTheme` has **4** values, not 2: `NEUTRAL` (default; mostly grayscale system_neutral1/2, accent1 only on the now-line -- this is what was originally called `WALLPAPER`), `ACCENT` (primary/secondary text itself tinted with system_accent1, now-line in accent2), `TONAL` (primary stays neutral for readability, secondary/muted pick up system_accent2/accent3), and `CLASSIC` (the original fixed white-on-transparent look, no wallpaper derivation at all). If asked for more/different mixes, add to this enum rather than replacing entries -- the user explicitly asked for "three mixes ... plus classic".
+
+**Grid lines (divider, now-line) must never carry alpha, in any theme including CLASSIC.** Fixed once for the wallpaper-derived palettes (see the alpha rule above), but `CLASSIC_PALETTE`'s divider/nowLine were still `Color.White.copy(alpha=...)` and got the same complaint on a later round ("drop the alpha on the grid lines"). They're now solid opaque grays (`Color(0xFF4D4D4D)` / `Color(0xFFD9D9D9)`). Text roles (textSecondary/textMuted) in CLASSIC still legitimately use alpha over white -- the alpha ban is specific to line/divider elements, not all of CLASSIC's styling.
+
+**Glance's `TextStyle` has no shadow parameter -- confirmed by decompiling `androidx.glance.text.TextStyle` through 1.3.0-alpha02 (the latest published build as of 2026-07-28), and RemoteViews doesn't expose `TextView.setShadowLayer` as a remotable action.** There is no version bump that adds this. A "drop shadow on the text" request is implemented instead via `ShadowedText()` in `WidgetRows.kt`: a `Box` stacking a black/alpha=0.35 copy of the text offset by `padding(start=1.dp, top=1.dp)` behind the real, correctly-colored text. Every visible `Text()` call in the widget grid (`WidgetRows.kt` + `DootWidget.kt`'s header) goes through this helper now -- don't add a new bare `Text()` call there without considering whether it should be a `ShadowedText()` instead, and route any *new* one through it too for consistency.
+
+**Workflow note:** each round in this session was build (`./gradlew :app:assembleDebug` + `testDebugUnitTest` on tungsten, toolchain already bootstrapped) → copy to `/site/static.terst.org/public/files/doot-widget.apk` → chown www-data → user reinstalls and reports back. See [[project-doot-widget-build]] for the build bootstrap details.
diff --git a/project_doot_android_roadmap.md b/project_doot_android_roadmap.md
new file mode 100644
index 0000000..50613a9
--- /dev/null
+++ b/project_doot_android_roadmap.md
@@ -0,0 +1,16 @@
+---
+name: project-doot-android-roadmap
+description: "doot Android app's native/web split and the offline-support requirement, from the 2026-08-07 architecture review"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 2607e018-d4ad-4ec6-bb68-5f1d0f9a3906
+---
+
+The Android app is deliberately split: a native shell (Glance widget, QuickAddActivity, TaskDetailActivity, SettingsActivity) for glance/sub-second-capture surfaces, and a WebView wrapper (DashboardActivity) around the real web dashboard for everything administrative (chains, buckets, recurrence, projects, labels). The user confirmed the "glanceable" test — native only for things that must work from the home screen without opening the app, or need sub-second capture — is the right seam and endorsed keeping it as the ongoing boundary.
+
+**User wants offline support for the Android app.** This was stated before 2026-08-07 (not captured in memory until now — an earlier architecture-review draft treated it as a hypothetical future trigger and the user corrected that directly: "you got off gesticulating about no need for offline support when I already told you I want it").
+
+**How to apply:** Don't treat offline support as optional or a someday-maybe in Android planning — it's a confirmed requirement. The architecturally consistent way to build it (matching the native/web split above): a service worker + IndexedDB write queue in the *web app itself* (caches the shell/last-fetched tab data, queues mutations like complete-atom/unified-add, replays on reconnect), not a native rewrite — this benefits a browser tab and the WebView-wrapped app identically and keeps doot's task/chain/bucket business logic living in one place (the Go server) instead of being reimplemented in Kotlin. Separately, the Glance widget talks to the bearer-token API directly over OkHttp and never touches the WebView, so a service worker does nothing for it — its own offline behavior (does a checkbox tap or quick-add fail silently when offline, or queue?) needs a small native fix of its own (a Room-backed retry queue flushed by the existing WorkManager refresh cycle), independent of the web app's offline work.
+
+See also [[project_doot_widget_build]] for the widget build/publish workflow.
diff --git a/project_doot_isallday_midnight_bug.md b/project_doot_isallday_midnight_bug.md
new file mode 100644
index 0000000..bd5bf30
--- /dev/null
+++ b/project_doot_isallday_midnight_bug.md
@@ -0,0 +1,22 @@
+---
+name: project-doot-isallday-midnight-bug
+description: "FIXED 2026-08-10 (commit 9a3ece4): dated doot-native/Google tasks landed in the widget's undated/floating bucket (rendering as due 'today') instead of their correct day, because every task due-date is midnight-anchored and trips the IsAllDay 'is this all-day' heuristic"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 647d8c60-e602-440e-b8b3-d8422fcbcb1d
+---
+
+**Status: FIXED and deployed**, commit `9a3ece4` on 2026-08-10. Originally diagnosed 2026-07-29 and deliberately set aside; recurred as a fresh report ("tasks scheduled for tomorrow show up right on the webview but on today in the widget") which prompted implementing it.
+
+**Root cause:** doot-native and Google Tasks due dates are always midnight-anchored (even with a real due date), which trips `TimelineItem.ComputeDaySection`'s "midnight means no specific time" heuristic and sets `IsAllDay = true` on them. `TimelineItemToWidgetItem` then left `wi.Start` nil for any Task/GTask with `IsAllDay == true`, and the Android client's undated/floating pool (`DootWidget.kt`'s `floating` list) has zero per-day awareness — it just packs items forward from "now" — so a task due tomorrow rendered as if due today.
+
+**Important correction to the original 2026-07-29 fix plan:** that plan proposed gating `ComputeDaySection`'s midnight heuristic to `Event`/`Meal` types only. Re-verifying it against the live code (rather than implementing on trust) surfaced a real problem: it would also flip `IsAllDay` to `false` for *same-day* dated tasks, moving them on the **web** from the untimed-item strip into the hourly calendar grid with a fabricated "12:00 AM" time label — fixing the widget by breaking the web's already-correct rendering. `IsAllDay`/`ComputeDaySection` is shared by both clients; the web buckets by `DaySection` (computed from `Time`, unaffected by `IsAllDay`) and only uses `IsAllDay` for the same-day untimed-vs-grid visual choice, which the naive fix would have broken.
+
+**Actual fix implemented:** added `TimelineItem.Undated bool` — a signal genuinely independent of `IsAllDay`, true only for the caller's two genuinely-dateless constructions in `internal/handlers/timeline_logic.go` (nativeUndated tasks, gtasks with no due date), both of which use `Time = now` as a layout placeholder rather than a real date. `ComputeDaySection`/`IsAllDay` were left completely untouched (zero web-side risk). `TimelineItemToWidgetItem` routes on `Undated` instead: dated Task/GTask items now get `wi.Start` populated (so the Android client's existing, already-correct Start-based day bucketing — `todayScheduledEvents`/`tomorrowItems` in `DootWidget.kt` — runs for them), while genuinely undated ones keep the original nil-Start floating treatment.
+
+Also fixed the identical latent bug in `wi.DueDate`'s guard in the same pass: it only checked `!Time.IsZero()`, but `nativeUndated`'s `Time=now` placeholder is non-zero, so an undated task's Android detail popup would show a fabricated due date. Now guards on `!Undated` too.
+
+**Verification:** new tests `TestTimelineItemToWidgetItem_DatedGTaskGetsStart` and `_UndatedTaskWithPlaceholderTime_NilDueDate`, plus three existing tests updated because they had encoded the bug's "Start stays nil for any IsAllDay task" as expected behavior. Proved the fix by reverting `widget.go` to the pre-fix condition against a real backup and confirming exactly the three tests exercising the new behavior failed, then restored and confirmed byte-identical. `go build`/`vet`/`test` all clean; deployed to tungsten and health-checked.
+
+**Takeaway for future similar reports:** when a proposed fix and its own diagnosis are more than a session old, re-derive the trace against current code before implementing rather than trusting the old plan verbatim — the plan itself had a real, would-have-shipped bug (web-side regression) that only surfaced from re-checking, not from the original diagnosis.
diff --git a/project_doot_prod.md b/project_doot_prod.md
new file mode 100644
index 0000000..33e00a4
--- /dev/null
+++ b/project_doot_prod.md
@@ -0,0 +1,48 @@
+---
+name: project-doot-prod
+description: "Production deployment facts for doot.terst.org — deploy target is tungsten ONLY (migrated from titanium 2026-07-17); verified architecture facts as of the first real deploy on 2026-07-17"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 31afbee3-b8e9-4163-a1bf-ea5392aef019
+---
+
+**Update (2026-07-17): production migrated from titanium to tungsten.** The deploy host is now **tungsten** — the user confirmed this explicitly ("we migrated to tungsten so that is now the deploy host"). This same day, the first real deploy since the migration ran successfully from a tungsten sandbox and corrected several stale facts below (previously copied from a box that only *resembled* prod — see the cautionary tale). Everything in this file is now verified against that real deploy, not inferred from resemblance.
+
+**Verification: run `hostname`.** Reports `tungsten` on the deploy host. Always check before any deploy-shaped action; if it's not `tungsten`, do the build/test/commit/push work and stop there, or ask. `scripts/deploy` (see below) now enforces this itself and refuses to run anywhere else — but still verify independently before invoking it, since the classifier gating deploy-shaped Bash actions wants fresh, visible confirmation in the same turn, not a memory citation.
+
+**Cautionary tale (pre-migration, still the reason for the rule):** In one session, "deploy = build + restart" was performed on a sandbox that wasn't the deploy host: built the Go binary in place, ran DB migrations against its `dashboard.db`, restarted the service — believing this was production. It wasn't; had to be unwound (stop/disable service, restore DB from backup, delete the built binary). Lesson: **confirm the current host is the deploy host before any deploy-shaped action — don't infer it from environment resemblance.**
+
+---
+
+**Real deploy mechanism, confirmed 2026-07-17:** run `scripts/deploy` from the repo root (e.g. `/workspace/doot`). It now has a `DEPLOY_HOST="tungsten"` guard at the top that hard-exits if `hostname` doesn't match — added after that session's classifier explicitly gated the raw deploy steps and asked for this kind of self-enforcement. The script: builds CSS (`npm run css:build`), builds the Go binary with `-ldflags` version stamping, stops the service, rsyncs `web/static`→public, `web/templates`, and `migrations` into the site dir, moves the binary in, fixes ownership/perms, restarts the service, and prints `systemctl status`.
+
+**Two other "deploy" scripts existed and were deleted 2026-07-17 as abandoned scaffolding:** `deployment/deploy` (a near-duplicate of `scripts/deploy`) and `deployment/post-receive` (a git post-receive hook meant to be installed at `/site/doot.terst.org/app-code/hooks/post-receive` for a push-to-deploy workflow) — confirmed the hook was never actually installed (no `.git/hooks/post-receive` existed) and the `app-code` checkout it depended on was stale (some files untouched since Jan, well predating current work). That checkout has since been `rm -rf`'d. `deployment/apache.conf` and `deployment/task-dashboard.service` are still sitting in the repo — known stale/needs-a-new-home per the user, but no destination decided yet, so left in place.
+
+**Verified real paths (previous versions of this memory had these wrong — they were guessed from a resembling sandbox, not checked):**
+- Binary: `/site/doot.terst.org/app` (NOT `/workspace/doot/app` — that's just the source checkout the deploy script builds from before moving the binary over)
+- DB: `/site/doot.terst.org/data/dashboard.db` (NOT `/workspace/doot/dashboard.db`, and NOT `/site/doot.terst.org/dashboard.db` either — that top-level path is a stale 0-byte decoy file that's easy to query by mistake; the real one is one level deeper, in `data/`. Confirmed via `DATABASE_PATH` in `/site/doot.terst.org/.env`.)
+- Env: `/site/doot.terst.org/.env`
+- Service: **`task-dashboard@doot.terst.org.service`** (a templated unit, `/etc/systemd/system/task-dashboard@.service`) — NOT `doot.service`. `systemctl restart task-dashboard@doot.terst.org.service` / `journalctl -u task-dashboard@doot.terst.org.service`.
+- Static/templates/migrations synced into `/site/doot.terst.org/public`, `/site/doot.terst.org/templates`, `/site/doot.terst.org/migrations` respectively by the deploy script.
+- DEFAULT_PASS in .env is a placeholder (user already exists in DB; EnsureDefaultUser only runs if users table is empty)
+
+**Fresh-sandbox gotcha: `node_modules` can look installed but be broken.** `npm install` reported "up to date, audited 107 packages" while the actual `postcss` CLI binary was non-functional (`Cannot find module '.../node_modules/.bin/lib/args.js'`), because `node_modules` only had 98 stale entries and was missing `postcss-cli` entirely. Fix: `rm -rf node_modules && npm ci` (there's a `package-lock.json`) before trusting `npm run css:build` / `scripts/deploy`.
+
+**Migration system tracks by full filename, not numeric prefix** (`internal/store/sqlite.go` `runMigrations`, table `schema_migrations`). See [[feedback-migration-renumbering]] for the incident this caused (a renamed already-applied migration crash-looped the service after this deploy) and the rule going forward (never rename/renumber an existing migration file to resolve a merge collision).
+
+**Git config:** `git config --global --add safe.directory /site/git.terst.org/repos/doot.git` has already been granted and applied (global, persists across sessions on this box) — pushes to the `local` remote from a root shell won't hit the "dubious ownership" error again for this specific path. A similar error will still occur for other www-data-owned paths (e.g. it happened for the now-deleted `/site/doot.terst.org/app-code`) until the same exception is added for them too, and that always needs to be asked for explicitly first (never run `git config --global` unprompted).
+
+**Reverse proxy: Apache (NOT Caddy) — this memory previously had it backwards.** Corrected 2026-08-04 after checking live: `caddy.service`/`caddy-api.service` are both `disabled` and not running; Apache2 is what's actually listening on :80/:443 (confirmed via `ss -tlnp` and `apache2ctl -S`). The `doot/deployment/apache.conf` mentioned above as "stale/unused" was WRONG — it's real config, just not the one checked into the repo (live vhosts are `/etc/apache2/sites-available/doot.terst.org.conf` and `doot.terst.org-le-ssl.conf`, proxying `/` to `http://127.0.0.1:38080/` — the app's actual port per `PORT=38080` in `.env`, not 8080 as previously assumed here). Apache's `ProxyPass` config is separate from any TLS cert renewal mechanism, so also don't assume Caddy handles ACME either — check `/etc/letsencrypt/` (certbot) vs Caddy's automatic HTTPS before trusting either claim. This was learned the hard way: don't trust old infra claims in memory without a live check when debugging anything proxy/reachability related. See [[feedback-verify-before-asserting-root-cause]] for the fuller incident writeup this correction came out of.
+- stash.terst.org → localhost:8000 (unverified whether this one's still Caddy or also moved to Apache — recheck before relying on it)
+
+**Playground web demo:**
+- /workspace/playground/web/server.py (stdlib Python, port 9090)
+- Service: /etc/systemd/system/playground-web.service
+- Exposed via doot gateway at /playground/ (PLAYGROUND_URL=http://127.0.0.1:9090 in doot .env)
+
+**Other services:**
+- stash: /workspace/stash (FastAPI/uvicorn, port 8000) — separate postgres on 5432
+- Claudomator: not currently running; would be on port 8484
+
+**Git remotes:** `github` (github.com:thepeterstone/doot.git) works from any sandbox. `local` (/site/git.terst.org/repos/doot.git) is a local filesystem path that only exists on the deploy host itself (tungsten) — its absence on a given box is itself a signal that box is NOT the deploy host.
diff --git a/project_doot_widget_build.md b/project_doot_widget_build.md
new file mode 100644
index 0000000..232cf84
--- /dev/null
+++ b/project_doot_widget_build.md
@@ -0,0 +1,30 @@
+---
+name: project-doot-widget-build
+description: "How to build and publish the doot Android widget APK from a fresh sandbox (toolchain bootstrap, signing choice, publish location)"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: f8b83784-e0ed-462c-8310-7de6aadc9650
+---
+
+**Always `git fetch github && git merge --ff-only github/master` before building.** First time this was done, the sandbox's checkout was 3 days stale and missing 90 commits (a full widget refactor plus recurrence/labels-projects/budgets/multi-day-events/text-size features) — the APK got built and published from that stale tree with none of it. Don't trust `git status`'s "ahead of X by N commits" framing without checking which remote — the `local` remote (`/site/git.terst.org/repos/doot.git`) hits a "dubious ownership" error in sandboxes and its stale tracking ref can make the branch look ahead when it's actually just behind `github/master`. Check divergence against `github/master` specifically (`git log --oneline master..github/master`), and don't run `git config --global --add safe.directory` to silence the `local` remote error without asking first — fixing it isn't necessary since `github` is the remote that matters for this workflow.
+
+**Build environment is not preinstalled in a fresh sandbox** — bootstrap before building:
+- Gradle: `android/gradlew` prefers a system Gradle at `/opt/gradle/gradle-8.6/bin/gradle` and only falls back to the repo's embedded wrapper jar if that's missing. The fallback path is broken in this environment (`NoClassDefFoundError: org/gradle/wrapper/IDownload`), so always install Gradle 8.6 to that exact path first: download `https://services.gradle.org/distributions/gradle-8.6-bin.zip`, unzip to `/opt/gradle/`.
+- Android SDK: install to `/opt/android-sdk` (matches `android/local.properties` → `sdk.dir=/opt/android-sdk`). Use the cmdline-tools zip (`commandlinetools-linux-*_latest.zip` from `dl.google.com/android/repository/`), place under `/opt/android-sdk/cmdline-tools/latest/`, then `sdkmanager --sdk_root=/opt/android-sdk "platform-tools" "platforms;android-34" "build-tools;34.0.0"` (accept licenses with `yes |`). As of 2026-08-06 also installed `"build-tools;36.0.0"` and `"platforms;android-36"` / `"system-images;android-36;google_apis;x86_64"` — see the target-device note below for why.
+
+**Target device is Android 16 (API 36) — minSdk=targetSdk=compileSdk=36 is correct, not a bug to "fix" toward broader compatibility.** This is a single-user app sideloaded onto exactly one phone, never distributed broadly, so "what does the widest range of devices support" is the wrong framing entirely — corrected 2026-08-06 after a prior pass (2026-08-05) wrongly walked minSdk back to 31 assuming "most real devices" needed supporting, without checking what device this actually runs on. Don't re-apply that "fix" — ask or check first if a future session is tempted to lower these again. AGP 8.2.0's bundled D8 prints `API level of 36 is not supported by this compiler` at compileSdk/targetSdk=36 regardless of build-tools;36.0.0 being installed (AGP doesn't delegate to the standalone SDK build-tools binary) — this is harmless noise, not a real problem: verified by actually installing and launching the resulting APK on a real API 36 target (see below), confirmed no crash, `target_sdk_version=36` correctly applied. A genuine fix would mean upgrading AGP itself, a bigger and riskier change not currently justified.
+
+**APK file size jump (11MB -> 27MB) is from minSdk=36 alone, NOT code bloat -- don't re-diagnose this as a Compose/Glance/dependency problem.** Isolated and verified 2026-08-06 by rebuilding the pre-minSdk-36 commit (0400e59) in a throwaway git worktree, changing ONLY minSdk 26->36 with zero other changes, and diffing the packaged APK: `classes.dex` goes from 63%-compressed (Deflate) to 0% (Stored) the moment minSdk crosses whatever threshold AGP uses to auto-switch to uncompressed dex packaging (ART on modern API levels can mmap dex directly from the APK without a decompression step at install time -- larger file, faster/lower-memory install, a deliberate platform tradeoff). An earlier same-day attempt to explain this via `apkanalyzer dex packages` (measuring Compose/Glance/DataStore's share of the dex class tree) was a dead end -- that metric stayed ~11MB in both the small and large builds, because it measures reachable-class-tree size, not the packaged/compressed byte size that actually determines the APK file size. If asked again "what got bigger," check compression method via `unzip -v <apk> classes.dex` (Cmpr column) before reaching for a dependency-based explanation.
+
+**Dedicated API 36 test AVD: `doot_test_api36`** (`system-images;android-36;google_apis;x86_64`, created 2026-08-06, separate from [[project-feedreader-emulator|feedreader_test]] -- don't reuse that one for doot, it's a different project's dedicated resource). Boot with `emulator -avd doot_test_api36 -no-window -no-audio -no-boot-anim`, then poll both `getprop sys.boot_completed` AND `getprop dev.bootcomplete` (both must be `1`) before `adb install` -- installing too early fails with `Can't find service: package` or `Error: device is still booting.` even after `sys.boot_completed=1` alone. Use this AVD to verify installability/launch after any build.gradle.kts SDK-version change, the same way the minSdk=36→31 mistake was originally caught: don't just declare a target API and assume it works, actually install onto a real matching target and confirm no crash via `logcat | grep FATAL`.
+
+**Build debug, not release:**
+- `android/app/build.gradle.kts` has a `release` signingConfig pointing at `android/doot-widget.keystore` — that file is gitignored and not present in any sandbox seen so far (only exists wherever the original release key was generated, possibly the deploy host or a personal machine — never found it).
+- Even if it were available, **prefer the debug build anyway**: sideloaded release-signed APKs from a low-reputation self-signed key get flagged hard by Google Play Protect ("app blocked as harmful"). Sibling projects [[project-doot-prod|can-can and nav]] avoid this by never wiring a release signingConfig at all and just shipping debug-signed APKs for sideloading (nav even has a checked-in `app-debug.apk`). Matched that pattern for doot's widget.
+- Command: `cd /workspace/doot/android && export ANDROID_HOME=/opt/android-sdk && ./gradlew :app:assembleDebug`
+- Output: `android/app/build/outputs/apk/debug/app-debug.apk`
+
+**Publish location:** copy the built APK to `/site/static.terst.org/public/files/doot-widget.apk`, then `chown www-data:www-data` it. A `doot-widget.apk` already existed there from a prior session (this is the established convention/URL the user installs from), so this is an overwrite-in-place, not a new path.
+
+**How to apply:** This publish step is a plain static-file write, not a service restart or DB migration — it does **not** fall under the "only deploy on tungsten" rule in [[project-doot-prod]] (deploy host migrated from titanium to tungsten 2026-07-17). That rule is specifically about the doot *server* (systemctl restart, dashboard.db migrations). Publishing the widget APK to the static file server is safe to do from any sandbox as long as `/site/static.terst.org/public/files/` is reachable.
diff --git a/project_feedreader.md b/project_feedreader.md
new file mode 100644
index 0000000..d884ee4
--- /dev/null
+++ b/project_feedreader.md
@@ -0,0 +1,25 @@
+---
+name: project-feedreader
+description: "FeeDreader (formerly \"Feeds\") Android RSS reader app (org.terst.feedreader) built for Peter — standalone greenfield project, separate from doot"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 375c858e-e817-41e8-a076-7920d22eab2b
+---
+
+Building "FeeDreader" (renamed 2026-07-23 from "Feeds" — pun on "fee dreader", avoiding
+paywalls), a standalone Android RSS/Atom reader app for Peter, package `org.terst.feedreader`,
+repo at `/workspace/feedreader` (separate git repo from doot, same local git identity).
+Greenfield build authorized with "just go for it - greenfield, simple design, just build."
+
+Core feature set: RSS/Atom subscriptions (now with OPML-category folder grouping +
+case-insensitive sort, and a virtual "Last fetch failed" section), full article +
+WebView/Readability.js reader-mode view, OPML import/export, keyword mute/highlight filters,
+save-to-Stash integration (explicit action, not automatic), Android share-sheet target.
+Explicitly excluded: local full-text search, home-screen widget, notifications, social
+features. Has an owl-themed adaptive launcher icon (hand-authored vector, no external design
+tool available) and a short paywall-avoidance philosophy note in Settings.
+
+**Why:** Inoreader (his prior RSS reader) removed OPML export, forcing a migration off it — his ~107 feeds were pulled via Inoreader's Reader API (ClientLogin) and re-packaged as OPML for import into this app.
+
+**How to apply:** Peter's workflow for this app is hands-on testing rounds — he sideloads a build to his phone, uses it for a while, then sends a batch of bug reports/feature requests in one message. Expect iterative APK rebuild-and-resend cycles, not a single big spec upfront. Room DB schema is at v6 as of 2026-07-23 (added `FeedEntity.folder`); see `AppDatabase.kt`'s migration comments — several past migrations needed a full table rebuild (Room validates exact column match, not just superset) rather than a plain ADD COLUMN. See [[feedback_feedreader_apk_naming]] and [[project_feedreader_emulator]].
diff --git a/project_feedreader_emulator.md b/project_feedreader_emulator.md
new file mode 100644
index 0000000..6b11e78
--- /dev/null
+++ b/project_feedreader_emulator.md
@@ -0,0 +1,26 @@
+---
+name: project-feedreader-emulator
+description: "Android emulator set up on tungsten for direct testing of the feedreader (\"Feeds\") app instead of relying only on Peter's phone"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 375c858e-e817-41e8-a076-7920d22eab2b
+---
+
+An Android emulator is provisioned on tungsten (the doot/feedreader host) for testing the "Feeds" app directly: AVD name `feedreader_test`, API 34, google_apis, x86_64, pixel_6 profile. Launch headless: `-no-window -no-audio -no-boot-anim -gpu swiftshader_indirect`. KVM-accelerated (host has `/dev/kvm` + AMD SVM). Controlled via `adb shell input tap/swipe`, `adb shell screencap`, and `adb shell uiautomator dump` for exact element bounds (more reliable than estimating tap coordinates from screenshots).
+
+**Why:** Peter suggested this ("since you're on tungsten you should have enough resources to install an emulator") after a hard-to-reproduce "crash loop" bug report — sideload-to-phone-and-wait-for-feedback cycles were too slow for debugging. With the emulator, a real crash (WebView touched off the main thread during Readability-extraction cancellation) was reproduced directly via scripted rapid-swiping through articles, root-caused from an actual stack trace, and verified fixed under a heavier stress test — instead of guessing from code review alone.
+
+**How to apply:** For future feedreader bugs, prefer reproducing on this emulator (install APK, drive via adb, capture logcat) before making a fix, rather than shipping a guess and waiting on Peter's phone-based feedback. `adb exec-out run-as org.terst.feedreader sqlite3 /data/data/org.terst.feedreader/databases/feedreader.db "..."` can query the on-device Room DB directly to verify behavior (e.g. confirming isRead flips). The SDK at `/opt/android-sdk` isn't on PATH by default — export `PATH=$PATH:/opt/android-sdk/platform-tools:/opt/android-sdk/emulator` first, and check `adb devices` since the emulator is often already running.
+
+To test `ReaderExtractor` against an arbitrary URL not in a subscribed feed, fire a share
+intent at `ShareReceiverActivity` rather than adding a throwaway subscription:
+`adb shell am start -a android.intent.action.SEND -t text/plain --es android.intent.extra.TEXT "<url>" -n org.terst.feedreader/.ui.ShareReceiverActivity`
+— it extracts and saves to the Saved list (isSaved=true), viewable afterward.
+
+**Caveat confirmed 2026-07-23:** extracting a heavy, ad/tracker-laden real-world page (e.g. an
+Ars Technica article) through this emulator is genuinely flaky — the same URL can time out,
+succeed fully, or succeed with less content (some page sections render in via client-side JS
+some time after `onPageFinished`, racing against when our extraction script runs) across
+back-to-back identical attempts with zero code changes in between. Retry 2-3 times (waiting
+the full ~20-30s) before concluding a code change broke extraction.
diff --git a/project_marine_cert_prep.md b/project_marine_cert_prep.md
new file mode 100644
index 0000000..af3ca16
--- /dev/null
+++ b/project_marine_cert_prep.md
@@ -0,0 +1,16 @@
+---
+name: project-marine-cert-prep
+description: "What the user's \"Marine Cert Prep\" doot chain is for — NMEA MEI certification study plan"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 119bd1a5-fb32-4222-9695-fcbe2c8fd63b
+---
+
+The user is working toward NMEA Marine Electrical Installer (MEI) certification. Tracked as a 16-step locked/linear task chain in doot (see [[reference_doot_marine_codemap]] for exact IDs/DB/code locations).
+
+Created 2026-07-17. As of creation, only step 0 ("Pass NMEA DC Electrical Overview online prerequisite") is unlocked/due; the rest unlock one at a time on completion (WIP-1 chain).
+
+Plan covers, in order: the NMEA online prerequisite, ABYC E-11 DC electrical rules (7-40-72 rule, battery switch placement, AIC parameters, temperature/bundling de-rating, voltage-drop budgets), a wire-sizing cheat sheet, AC/DC grounding and ELCI/galvanic-isolation rules, a distance/dimension cheat sheet, the ABYC Marine Electrical practice exams (target >85% before booking the proctored exam), an FCC Amateur Radio General license (via HamStudy.org, focused on G5/G6/G9), and finally a logged hands-on volunteer electrical audit at Honokohau to start the 1-year NMEA experience clock.
+
+**How to apply:** treat this as a personal certification study project, not a software project — "code" work here means reading/updating doot's chain data (via DB or the chain API/UI), not writing new features, unless the user separately asks for a doot code change.
diff --git a/reference_doot_marine_codemap.md b/reference_doot_marine_codemap.md
new file mode 100644
index 0000000..4d46fe1
--- /dev/null
+++ b/reference_doot_marine_codemap.md
@@ -0,0 +1,31 @@
+---
+name: reference-doot-marine-codemap
+description: "Code map for locating the \"Marine\" project (Marine Cert Prep task chain) in doot's DB and Go source"
+metadata:
+ node_type: memory
+ type: reference
+ originSessionId: 119bd1a5-fb32-4222-9695-fcbe2c8fd63b
+---
+
+The user's "marine project" is a **doot task chain** named "Marine Cert Prep" — NOT a separate repo. It lives entirely inside the [[project_doot_prod]] `doot` app's data and code.
+
+## Identifiers
+- `project_id = 410a417491154b18ee3ff9cb` (row in `projects` table, name "Marine Cert Prep", color `#8B5CF6`)
+- `chain_id = 4ad4ec5d4b133658fb3e0ac0` (row in `task_chains` table, status `active`), also cached at `/tmp/marine_chain_id.txt`
+- 16 sequential `native_tasks` rows (`chain_position` 0-15) with `chain_id` set to the above — a linear WIP-1 study plan for the NMEA Marine Electrical Installer (MEI) certification, covering ABYC E-11 standards, DC electrical calcs, an FCC Amateur Radio General exam, and a hands-on audit log.
+
+## DB access
+- Production DB: `/site/doot.terst.org/data/dashboard.db` (SQLite, owned by `www-data`). WAL files sit alongside it; there's also a `.bak-<timestamp>` snapshot from the last migration.
+- No `sqlite3` CLI installed on this box — query via `python3 -c "import sqlite3; ..."` instead.
+- Relevant tables: `projects`, `task_chains`, `native_tasks` (has `chain_id`/`chain_position`/`chain_unlocked` columns, added in `migrations/026_task_chains.sql`).
+- Schema source of truth: `/workspace/doot/migrations/026_task_chains.sql` (chains) and `020_native_tasks.sql` (base table).
+
+## Go code (repo: `/workspace/doot`)
+- Store layer: `internal/store/chains.go` — `CreateChain`, `GetChain(id)`, `GetChains()`, `GetChainTasks(chainID)`, `SetChainStatus(id, status)`, `advanceChain` (unlock-next-on-complete logic).
+- Task completion hook that advances chains: `CompleteNativeTask` in `internal/store/native_tasks.go` (returns `ErrChainTaskLocked` if you try to complete a locked step).
+- Web handlers: `internal/handlers/chains_web.go` — `BuildChainSummaries`, `HandleChainDetailView`, `HandleChainPause/Resume/Abandon`.
+- Routes (`cmd/dashboard/main.go` ~L330-414): web `GET/POST /chains/{id}[/pause|resume|abandon]`; widget API `POST /api/widget/chains`, `GET /api/widget/chains/{id}`, `POST /api/widget/chains/{id}/{pause,resume,abandon}`.
+- Agent Context API (external read/write access, bearer-token gated): `internal/handlers/agent.go` — `HandleAgentContext` (GET `/agent/context`), task write/complete endpoints. Auth flow: `POST /agent/auth/request` → browser approval → `GET /agent/auth/poll`.
+
+## How to apply
+When the user says "my marine project," resolve it to `project_id 410a417491154b18ee3ff9cb` / `chain_id 4ad4ec5d4b133658fb3e0ac0` and go straight to the DB/code above rather than searching for a "marine" repo or directory — there isn't one. [[feedback_deploy_classifier]] and [[feedback_pull_before_build]] still apply if any change here touches the live doot deploy.
diff --git a/reference_google_tasks_due_date_utc_quirk.md b/reference_google_tasks_due_date_utc_quirk.md
new file mode 100644
index 0000000..a0025dc
--- /dev/null
+++ b/reference_google_tasks_due_date_utc_quirk.md
@@ -0,0 +1,16 @@
+---
+name: reference-google-tasks-due-date-utc-quirk
+description: "Google Tasks API's due date field is date-only but always serialized as midnight UTC -- converting the instant into a display timezone (vs. re-anchoring the date) shifts dates in negative-offset zones"
+metadata:
+ node_type: memory
+ type: reference
+ originSessionId: 647d8c60-e602-440e-b8b3-d8422fcbcb1d
+---
+
+The Google Tasks API's `due` field is conceptually date-only (no time-of-day is meaningful), but the API always serializes it as an RFC3339 timestamp fixed at midnight UTC, regardless of the user's actual timezone. E.g. a task due "July 29" comes back as `"2026-07-29T00:00:00.000Z"` even for a user in Honolulu.
+
+**The bug this causes:** if code does `dueDate.In(displayTZ)` on that parsed instant (converting the moment-in-time into the display timezone) instead of re-anchoring the same Y/M/D to local midnight, the date silently shifts backward for any timezone behind UTC. Midnight UTC becomes 2pm the *previous* day in UTC-10 (Pacific/Honolulu, doot's configured `config.GetDisplayTimezone()`) -- so every dated Google Task landed one calendar day earlier than Google actually reported. Symptom: "I have a scheduled task for tomorrow that's showing up today."
+
+**The fix** (`internal/api/google_tasks.go`, `getTasksFromList`): extract `y, m, d := dueDate.Date()` from the UTC-parsed timestamp (that Y/M/D *is* the intended calendar date) and rebuild `time.Date(y, m, d, 0, 0, 0, 0, displayTZ)` from those components -- never call `.In(displayTZ)` on a Google Tasks due-date instant directly. Fixed 2026-07-28, regression-tested in `google_tasks_test.go` (`TestGetTasksFromList_DueDateNotShiftedByNegativeOffsetTZ`) using the real Pacific/Honolulu location via a mocked HTTP tasks-list response.
+
+**How to apply:** if any *other* date-only field from an external API gets added to doot (a new integration, or a different Google Tasks/Calendar field), check whether that field has the same "date-only-but-serialized-as-UTC-midnight" convention before doing a naive `.In(displayTZ)` conversion on it. This is a genuinely common API convention (RFC3339 doesn't have a "date-only" type), not unique to Google Tasks.