Timeline

How my thinking changed while building.

A thought earns a line here by having been thought, on a date, about real work. Evidence sits underneath one when it exists and is absent when it does not — an idea that has not been tested yet is still a real position, and it says so.

Entries are not edited when a later one contradicts them. An entry that turned out to be wrong stays where it is, in the words it was written in, with a line pointing at what replaced it. Dates mark when a thought was recorded, not when it became correct, and where I cannot establish the origin of one the entry says that instead of inventing a date. Every entry names the repository it rests on — one public, three private, one local and unpublished — and the commands behind the numbers are in the evidence appendix.

2026

Aug 29 Nothing on main carries the receipt the contract requires VERIFIED
Context
Auditing whether V2's authority model was actually in force, rather than reading the contract as a description of the running system.
Observation
AGENTS.md invariant 12 requires every commit to carry an Authorization-Event-Key trailer, and commit f019bc9 added a gate that refuses commits without one. Across all 90 commits on basic-system-v2 main, that string appears exactly once — in a line of documentation. No commit carries it, including the seven that landed after the gate existed. In V1 the equivalent trailer, basic-system-unit:, is on 60 of 691 commits against 347 integrations.
Mental model
A rule reaches the paths that execute it, not the paths it names. The gate binds branch integration; the owner's own commits go straight to main and meet nothing. The rule is load-bearing on one path and decorative on the other, and nothing distinguished them — not the contract, not the repository, not this site.
What changed
This narrows the Aug 24 entry rather than overturning it. Shrinking the state space was the right move and it produced a written contract; what I got wrong was reading the contract as an account of what runs. Provenance you cannot count is provenance you do not have.
Evidence
  • git log --format='%B' | grep -c 'Authorization-Event-Key' → 1, on the documentation line only; 0 of 90 commits carry the trailer thun-viet/basic-system-v2 · private repository, reference not a link
  • the same count in V1: 60 of 691 commits carry basic-system-unit:, against 347 integrate commits thun-viet/basic-system · private repository, reference not a link
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
Nothing about the count. What is open is whether owner commits should carry receipts at all, or are legitimately outside the model. I have not decided that, and the gap is not yet a defect.
Aug 29 A gate with tests and no callers is not a gate VERIFIED
Context
Auditing how persona branches were actually arriving on main.
Original thought
I had been treating a green suite as evidence that a safeguard was working. A test that passes says the code under it behaves; it says nothing about whether anything reaches that code.
Observation
integration-queue.mjs carried validateHandoff, gitFacts and withIntegrationLock from the first commit, with tests, and zero production callers. No test ever went red for it, because the gate was not broken — it had simply stopped being called. The static import closure from eighteen entry points never reached it. 35 of 36 persona branches had reached main by manual fast-forward, through no gate at all.
Mental model
A unit test cannot see a gate that has been disconnected from the path that runs. Reachability has to be asserted from where the repository really wires things — package.json scripts, hooks, husky — not from a hand-written list a test can quietly fall behind.
What changed
The first version of the wiring test asserted the gate was "called from production code". It proved reachability only — a mutation to if (false) validateHandoff(...) survives it, because the import remains. The claim was narrowed to what the test demonstrates, and the limit is written in the file rather than left implied.
Application
A wiring test computes the static import closure from package.json scripts, .claude/settings.json and .husky, so removing the wiring turns the suite red rather than needing the test updated to follow. A deterministic call site was added (coordinator:integrate), commits must carry an Authorization-Event-Key receipt trailer, and a branch reaches main only by base_sha == main plus merge --ff-only.
Evidence
  • commit f019bc9 thun-viet/basic-system-v2 · private repository, reference not a link
  • commit 9c65bd1 thun-viet/basic-system-v2 · private repository, reference not a link
  • mutation results recorded in both commits: removing the gate from package.json turns 3 tests red; removing the integration-queue import turns 1 red; five separate mutations of the provenance path each turn the suite red
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
A persona branch arriving on main without a receipt trailer, or the suite staying green after the gate is removed from package.json.
Aug 29 Identity claimed by the acting process cannot bound that process VERIFIED
Context
Checking whether the boundary guard actually constrained a subagent, rather than assuming the environment it reads was populated.
Observation
THUNVIET_PERSONA and CLAUDE_AGENT_TYPE were read in this repository and set nowhere in it. Measured on a real repository and a real worktree with no environment injected, the guard printed "owner (no persona identity)" and exited 0 for every path. The invariant it existed to enforce was enforced nowhere outside four PreToolUse tool names.
Mental model
Identity has to be derived from where the commit is happening, not from the testimony of the process doing the committing. A process asked to declare what it is will declare whatever it was told to declare.
Application
Identity is derived from the worktree via a spawn record the coordinator writes before launch, read from the main checkout because the runtime directory is gitignored. The worktree beats the environment: a claim that contradicts the record is denied, two records for one worktree are denied rather than guessed, and no record falls back to the previous owner behaviour.
Evidence
  • commit c1ce150 thun-viet/basic-system-v2 · private repository, reference not a link
  • 5 wiring tests run against a real linked worktree and a real spawn record, with the guard as a child process and no environment injected
  • mutations: restoring the old derivation turns 3 red, dropping the env-mismatch check turns 1 red, dropping the ambiguous-record denial turns 1 red
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
A persona commit passing the guard while claiming an identity that contradicts its own spawn record.
Aug 28 A rule that carried no security weight was silently dropping trusted messages VERIFIED
Context
Instructions that were sent to the Slack wake bridge and never arrived.
Original thought
The bridge refuses by default and only acts on an exact keyword prefix. I had read that strictness as safety — the narrower the accepted shape, the smaller the attack surface.
Observation
Three owner instructions were dropped in one morning. Each opened with ACTION or CLARIFICATION followed by an em dash, while the prefix list matched an exact colon only. One of them said in its own body that it had been written so the bridge could ingest it. The sender was fully trusted throughout, and nothing was logged.
Mental model
The separator never carried the security weight — the trusted-sender check does. Requiring exact punctuation staked a delivery guarantee on a language model's typography habits, which is not a guarantee. The strictness added interpretation surface without adding safety, which is the general shape: a rule that does not sit on the trust boundary buys nothing and costs a failure mode.
Application
The keyword now accepts a colon, hyphen, en dash or em dash, case-insensitively, and CLARIFICATION joined the keyword list. The matched prefix is normalised before it reaches a log line or the inbox, so records no longer vary with typography. The bridge still refuses by default and still never interprets the message body.
Evidence
  • commit ff52a7e claude-slack-bridge · local repository, not published
  • 46 tests, 46 passing, executed 2026-08-29; the three added cases are the exact message shapes that were lost
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The exact command and its unedited output are reproduced in the evidence appendix. See the run →
What could change my mind
A trusted top-level instruction in an agreed shape that the bridge still drops without a record.
Aug 28 Directory order is the filesystem's business, not the test's VERIFIED
Context
CI produced an ordering on its first run that two dozen local runs never had.
Observation
The sealing loop deletes .part files left by an interrupted attempt and also creates a .part per photograph before renaming it into place. Those are the same path when the names collide, which they do by definition. listSync() is a snapshot taken before the loop runs, so if it ordered the photograph before the stale .part, sealing consumed the path by renaming it and the delete then threw — aborting the migration whose whole design is to survive being interrupted.
Mental model
A local suite passing repeatedly says nothing about an ordering the filesystem chooses. The delete should tolerate the file already being gone, which it should have from the start: a .part that has vanished was already consumed or already cleaned up, and neither is a reason to abandon a migration halfway.
Application
The delete tolerates absence. No test was added — the existing test already seeds exactly this collision and passes or fails on directory order, so a second one like it would pin nothing. CI is the environment that catches this, and it did.
Evidence
  • commit f26e3c2 thun-viet/one-frame · private repository, reference not a link
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
A migration aborting on a stale .part with the tolerant delete in place.
Aug 28 The live test was testing the harness, not the service VERIFIED
Context
An endpoint that appeared to be failing, on the day the pilot had to reach students.
Observation
TestWidgetsFlutterBinding.ensureInitialized() replaces HttpClient with a stub that answers 400 to everything and never opens a socket. The live smoke test called it, so every run failed with an empty-body 400 — which the client correctly reads as "the edge refused this", and which looked exactly like the service being broken. The same payload, byte for byte, returned 201 from curl.
Mental model
A test that reaches for the network from inside a widget harness is not testing the network. Before believing a service is down, run the same bytes outside the thing that reports it down.
Application
Removing the one line makes the test pass. The service did have a separate, real fault: a fine-grained token belonging to a collaborator account can only reach repositories owned by the account that created it, which the service now maps to an explicit error rather than a generic failure.
Evidence
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is public. Clone it and run the commands yourself. See the run →
What could change my mind
The smoke test failing against a service that curl shows is healthy, with the binding call removed.
Aug 28 A signing key with no reputation is not a distribution channel SUPPORTED
Context
Planning to hand an APK to students directly, with no Play Console account.
Original thought
A signed release artifact is a shipped product. Building it, signing it and validating it was the last step.
Observation
Two installs of comparable APKs from the same file manager on the same device, four minutes apart: the one signed with an established key installed by hand with no warning at 17:57; the one signed with a fresh key was blocked at 18:01.
Mental model
Play Protect appears to gate a sideloaded install on the reputation of the signing key, so a fresh key cannot be a distribution route. The mechanism is read from the behaviour and from nothing inspectable, which is why this is SUPPORTED and not VERIFIED. The wider point survives either way: an artifact that builds, signs and validates is not yet a product anyone can run.
Application
The app runs in a browser as well as on Android. Three files used dart:io, which is the trap — it compiles for web and throws when touched, so the build looked fine and the page would have died on first save. File and path_provider became shared_preferences on Android and localStorage in a browser; HttpClient became package:http with UTF-8 read and written explicitly, because http falls back to latin-1 when a server omits the charset and every Vietnamese diacritic would have become mojibake.
Evidence
Not established
Play Protect's actual decision rule. Two installs are a comparison, not a controlled experiment, and nothing about the mechanism is inspectable from outside.
Status
SUPPORTED — Observations point this way without establishing it.
How to check
The repository is public. Clone it and run the commands yourself. See the run →
What could change my mind
A fresh-key APK installing without a Play Protect warning on a comparable device, which would move the cause somewhere other than key reputation.
Aug 27 Fifty-three tests proved the model consistent, not the database VERIFIED
Context
Preparing a credit ledger that would charge real money for generation.
Original thought
Fifty-three green tests over the ledger read as a ledger I could trust.
Observation
Every one of the fifty-three tests ran against an in-memory double. It modelled optimistic concurrency and stale-read retry from a reading of the documentation rather than from the database, so the invariants holding meant the model was self-consistent — not that the database agreed with it. The adapter that would hold real money had never been executed once.
Mental model
A test against a model of a system is evidence about the model. Contested paths have to run against the real thing.
What changed
The first test written for the range lock passed with the query moved outside the transaction entirely, so it proved nothing. It was replaced with one that fails when the query leaves the transaction.
Application
Eleven tests now run the contested paths against the Firestore emulator, in a lane of their own so the offline suite stays free of Java and stays at seventy milliseconds. Three assumptions the ledger rests on became facts: create refuses an id that already exists, a transaction retries when its read set goes stale, and a query inside a transaction locks the range it read.
Evidence
  • commit 97bfe4c thun-viet/one-frame · private repository, reference not a link
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
An emulator run in which create accepts a duplicate id, a stale read set does not retry, or a range lock does not hold on an empty result.
Aug 27 Export is not backup until a restore runs on another device VERIFIED
Context
Restoring onto a cleared device, which is the case the passphrase exists for.
Observation
Export copied each photograph off disk still sealed with the exporting device's key, and restore wrote those bytes back untouched. On the phone that made the backup the key matches and nothing looks wrong. Anywhere else — a new phone, or the same phone after a reinstall — the key is gone and every restored photograph is a file nothing can open. The envelope opened, the sentences came back, and the pictures were silently unrecoverable.
Mental model
The existing round-trip test could not catch this, because it exports and restores with a single key. A backup is only demonstrated by a restore that crosses a device boundary. Local-first raises the stakes on this rather than lowering them: there is no server holding a second copy.
Application
Photographs now travel unsealed inside the envelope, which already encrypts the whole payload with AES-256-GCM, and the restoring device seals them with its own key on the way out. The schema moved to 2 so an older build refuses these archives rather than writing plaintext photographs to disk, and an unreadable photograph is counted as skipped or marked missing rather than failing the whole backup.
Evidence
  • commit 1268de1 thun-viet/one-frame · private repository, reference not a link
  • verified on a Redmi Note 10 Pro after pm clear: sentence restored, photograph a broken-image icon before the fix. The two new tests fail without it.
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
A cross-device restore in which a photograph comes back unopenable.
Aug 27 Three defects that analysed clean and passed 137 tests VERIFIED
Context
The previous commit recorded that none of the privacy work had been verified on hardware.
Observation
On the device, the privacy switch did not move when toggled, the lock screen rendered in Flutter's fallback text style with every line yellow and underlined, and the biometric prompt never appeared at all. All three sat in code that analysed clean and passed 137 tests.
Mental model
The widget harness supplies a MaterialApp, so it cannot see a gate that sits above the Navigator with no Material ancestor. The setting had been persisting the whole time, which is the worst version of the bug — the feature worked and the interface was lying about it. The biometric library is hosted by BiometricPrompt and refuses anything that is not a FragmentActivity, failing silently with no throw and no log.
Application
The shell listens to both controllers; a Material ancestor was added above the lock screen; the activity became a FlutterFragmentActivity.
Evidence
  • commit f39cb04 thun-viet/one-frame · private repository, reference not a link
  • verified on a Redmi Note 10 Pro, Android 11: solid-black recents card, app starts locked, BiometricPrompt in the window and in logcat, 0 FATAL
Not established
Completing the authentication needs my own finger, so the unlock path past the prompt is still unwitnessed. That is a boundary, not a gap.
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
Any of the three recurring on a device while the suite stays green.
Aug 26 A gate that cannot fail is not a gate VERIFIED
Context
A release-readiness audit, finding P0-1.
Observation
Release builds were signed with the debug key, which Google Play rejects outright. The first fix moved both pipelines to a release build but set the debug-signing escape hatch at job level, so the gate could not tell a correctly wired signing config from a bypassed one. A green run therefore said nothing about whether release signing still fell back to the debug key.
Mental model
A safeguard that is never observed failing has not been shown to work. The refusal itself has to be executed and asserted, and asserted for the stated reason rather than by accident.
Application
The release build is refused when android/key.properties is absent, with an actionable message instead of a silent fall back to the debug key. Both pipelines now run that build once without the escape hatch and require it to refuse; only then is the escape hatch applied, scoped to a single step, to produce the regression artifact — renamed app-release-ci-debugsigned.apk, because publishing it as app-release.apk invited exactly the mistake the audit was opened to prevent.
Evidence
  • commit b411636 thun-viet/one-frame · private repository, reference not a link
  • commit bd134b9 thun-viet/one-frame · private repository, reference not a link
  • verified locally in order: the refusal step exits non-zero with "Release build refused"; the QA build then succeeds; the notification icon survives shrinking; allowBackup=false confirmed via aapt2 dump xmltree
Status
VERIFIED — A narrow claim with direct, inspectable, reproducible evidence.
How to check
The exact command and its unedited output are reproduced in the evidence appendix. See the run →
What could change my mind
A release build succeeding with no key.properties and no escape hatch set.
Aug 24 A persona is whoever the running process says it is SUPERSEDED
Context
Building the write boundaries for V2, where each persona owns a disjoint set of globs and a PreToolUse guard refuses writes outside them.
Original thought
Give the launcher a persona name, put it in the environment, and have the guard read it. The environment is set by the thing doing the spawning, so it is trustworthy enough to bound the thing being spawned.
What changed
Five days later, measured rather than assumed, the environment turned out to be set nowhere and the guard permissive for every path.
Application
boundary-guard.mjs reads THUNVIET_PERSONA and CLAUDE_AGENT_TYPE and refuses writes outside the persona's ownership globs; commit db301d6 hardened it further so the guard takes its repository root from the agent's own git rather than from the hook script's location. AGENTS.md invariant 7 is written on top of this.
Evidence
  • commit db301d6, 2026-08-24 thun-viet/basic-system-v2 · private repository, reference not a link
  • .claude/hooks/boundary-guard.mjs and its test suite, which supplies THUNVIET_PERSONA itself and so could never have caught this thun-viet/basic-system-v2 · private repository, reference not a link
On the date
Dated by the commit that hardened the guard. The assumption underneath it was in place from the start of V2 and I cannot date its origin.
Status
SUPERSEDED — A later model replaced it. The original entry stays.
How to check
The repository is private. The commit reference is given; source access on request.
Aug 24 Reduce the state space instead of adding governance REVISED
Context
basic-system had reached 691 commits, 347 integrations and 99 worktrees, with a control plane of numbered policy clauses. Adding a rule had become the reflex answer to every failure.
Original thought
Every rule I add is another sentence an agent has to interpret, and every interpretation is a place to drift. Governance grows the surface it is meant to protect. The alternative is not better rules — it is fewer reachable states.
What changed
Five days on, the receipt requirement turned out to be running on one path only. The direction held; my reading of the contract as a description of the system did not.
Application
basic-system-v2 as a black-box rewrite. Reading V1's implementation is forbidden and guarded (exit 2); the only permitted oracle is behaviour. No full-stack persona: three narrow, non-overlapping write boundaries, and no two personas owning the same glob. Work requires an owner authorization receipt or it does not start. Ambiguity is a stop condition rather than something to interpret past.
Evidence
  • basic-system-v2/AGENTS.md — invariants 1, 5, 6, 7 and 10, and "no full-stack persona" thun-viet/basic-system-v2 · private repository, reference not a link
  • repository shape as a measurement: 90 commits and 7 worktrees, against V1's 691 and 99
Not established
The Aug 21 language and runtime audit that fed this listed pure core / imperative shell among its sources, and V2's API does land on ports and adapters with an in-memory fake and a fail-closed composition root (commits 520989b and fa008ac, Aug 27). Both facts are checkable. No document connects them, so the influence is plausible and undocumented, not established.
On the date
Dated by the first commit of the rewrite. The reasoning accumulated over the preceding week and has no single origin date.
Status
REVISED — The model changed materially. The original entry stays.
How to check
The repository is private. The commit reference is given; source access on request.
Aug 23 Do the agents share my model of the project? HYPOTHESIS
Context
Several sessions running at once against one repository, each having been briefed at a different time from a different slice of the state.
Original thought
Coordination protocols assume the parties agree on what is true. I had never checked that. If two sessions hold different pictures of the current milestone, the open decisions or what is already done, then every message between them is negotiation over a disagreement neither has noticed.
Observation
I asked several sessions the same single question — state the project's current objective, milestone, invariants, active work, blockers and open decisions as one JSON object — with a canonical context packet supplied and UNKNOWN required for anything not actually known. No guessing, no prose. It was run three times that day.
Mental model
If shared understanding has to be tested, it is not a property of the system; it is a hope about it. Consensus between agents is agreement on outputs, which is not the same as agreement on state, and neither is correctness.
Not established
The answers were not kept. The prompt is recorded three times on 2026-08-23; what the sessions replied is not. This entry is evidence of the question I was asking and no evidence at all of what the answers showed. It stays as a HYPOTHESIS for that reason.
Status
HYPOTHESIS — A working model that has not been strongly tested.
How to check
This rests on my own working record — dated session logs on my machine. It dates the thought; it is not something a reader can open, and it is not offered as proof of the claim.
Aug 21 One writer, not better negotiation APPLIED
Context
Four or more concurrent sessions, cross-session messaging, hand-offs and re-assignments. The coordination protocol was growing faster than the work it coordinated.
Original thought
I had been adding coordination rules — claim, release, broadcast, ask before you write. Each new rule was another sentence an agent had to interpret correctly, and interpretation is exactly where agents drift. More protocol was making the problem larger, not smaller.
Mental model
Make authority singular and physical instead of negotiated. One canonical write leader holding a durable lease; everyone else is a read-mostly follower. There is nothing to interpret — either you hold it or you do not, and the refusal is a process exit rather than a request to be polite.
Application
tools/agent/lease.mjs, 337 lines with its own test file; lease and slot clauses enforced in the control plane; a leader/follower split in daily use, where a session that cannot take the lease downgrades itself.
Evidence
  • commits 2d7a8624 (agent/resource-lease), 3899ed91 (agent/lease-authority) and e3508599 (control-plane lease enforcement), all 2026-08-21; 7dd5890a (agent/lease-pid-fix), 2026-08-22 thun-viet/basic-system · private repository, reference not a link
  • the refusal is observable in normal use: pnpm agent:lease release prints TỪ CHỐI · không phải leader and exits non-zero
Status
APPLIED — The idea materially shaped an implementation or a decision.
How to check
The repository is private. The commit reference is given; source access on request.
What could change my mind
A workload where a single writer is the throughput ceiling. It was not the ceiling here — the bottleneck was correctness, not parallelism.
Aug 17 Two sessions entered the same worktree anyway SUPPORTED
Context
Running dispatch waves of parallel agents, each supposedly isolated by a declared write scope.
Observation
Multiple sessions were found working in the same worktree on one story. A session detected the collision itself, stopped, and messaged its peers. Separately, a pull request was closed as a duplicate of another — two agents had independently built the same thing.
Mental model
Scope declaration constrains where a well-behaved worker intends to write. It does not constrain where a worker ends up, because the assignment and the process are two separate facts and nothing bound them together. Detection ran at plan time; the collision happened at run time.
What changed
This is the observation that made the scope-collision model insufficient rather than wrong. Detection kept its value; isolation was the thing it had never provided.
Application
A duplicate-worker regression suite, and the scope check extended to count workers already running rather than only the wave being planned.
Evidence
  • working record, 2026-08-17: three separate collision events, plus a pull request closed as a duplicate of one already delivered
  • the scheduler fix that followed — measure held scope, not live process count thun-viet/basic-system · private repository, reference not a link
Not established
How often this happened without being noticed. Only the caught cases are recorded, so this is a handful of observed incidents and not a rate.
Status
SUPPORTED — Observations point this way without establishing it.
How to check
This rests on my own working record — dated session logs on my machine. It dates the thought; it is not something a reader can open, and it is not offered as proof of the claim.
Aug 17 Parallel agents are safe if their write scopes do not overlap SUPERSEDED
Context
Running many sessions at once against one repository, each in its own git worktree, dispatched in waves.
Original thought
Concurrency is safe if you can prove the writers touch disjoint paths. Declare each unit's write scope, compute the overlap, and refuse the wave that collides.
Application
docs/backend/write-scopes.json declares scopes for eighteen backend stories; collision detection lands in the dispatch scheduler and surfaces in pnpm agent:plan; the full collision matrix was computed — sixty-nine collisions, all inside feature zones or dependencies.
Evidence
  • docs/backend/write-scopes.json, added in commit c49032ab, 2026-08-17 thun-viet/basic-system · private repository, reference not a link
  • collision logic and its regression tests under tools/agent/dispatch/ thun-viet/basic-system · private repository, reference not a link
  • 99 worktree directories under basic-system-worktrees/, which is the scale this model was carrying
Not established
There is no measurement showing collision detection ever prevented a collision. It was never instrumented, so its value is unproven in both directions — this entry says it was insufficient, not that it did nothing.
On the date
Dated by the artifacts that put the model into force. The belief predates the repository and I cannot date its origin.
Status
SUPERSEDED — A later model replaced it. The original entry stays.
How to check
The repository is private. The commit reference is given; source access on request.

Home Evidence appendix