Skip to content

Agent Guidelines

⚠️ Before doing anything: read this file AND scan the task board (docs/eop-tasks/tasks/, published at tasks.theestablishers.com). In EVERY response, include a short attestation that confirms all three: (1) you've read these guidelines, (2) you've checked the board by scope, not UID, so you're not duplicating an existing task, and (3) the task you're working under is named and kept current as you go β€” and state who you are: <owner-assigned name> Β· <session URL / local session UUID>. Example: βœ… Read AGENTS.md Β· <name> Β· <session id> Β· checked the board (no dup) Β· tracking T-082. For pure Q&A that opens no task, say so: … Β· no task (informational). Applies to every agent, local or cloud β€” and it's self-triggered: you do this because you read this rule, not because the owner asks.

Self-name in the task log: when writing into the task board / decision logs and you need to refer to yourself, sign with the agent name the owner assigned you β€” never "I", "the agent", a model name, a name you invented, or another agent's name.

A name only helps if you can be sure it's yours. Persist or auto-recall your name only where it uniquely identifies you. Agents that share an environment β€” e.g. multiple local Claude Code sessions in the same repo path, which share session memory and the same working tree β€” must not store their name in that shared space, because another agent would read it and adopt it as its own. If you're such a local / shared-environment agent, ask the owner what your name is at the start of the session. If you have no name yet, or can't be certain which is yours, ask before signing anything. The owner's roster of uniquely-identifiable agents is docs/agents-registry.md.

Self-identify via the session-URL ledger before asking the owner. Many agents carry a stable session URL in their system context β€” https://claude.ai/code/session_<ID>. Cloud Claude Code definitely does; local Claude Code may or may not β€” verify in your own environment. If yours does, at session start: read your own session URL, search docs/agents-registry.md's "Session ledger" subsection for a row that matches it, and if there's a match adopt the name listed against it without needing to prompt the owner. No match β†’ fall back to the prior rule: ask the owner. No session URL at all β†’ ask the owner; the ledger can't help. Once the owner names you, add your own append-only row to the ledger so a future agent (yourself on a long-running local session, or just an auditor reading the file) can resolve the identity. Never invent a name from an unmatched URL, and never edit / delete another agent's row β€” the ledger is append-only.

⚠️ Commit your work when it's done β€” every time, before ending your turn. Code edit, doc update, task-board entry, README pointer bump, anything β€” if you've completed the work, git add β†’ commit β†’ push on the designated branch before you reply with the wrap-up. Don't leave a dirty working tree for "the next message": the harness may compress, the session may sleep, the change may be quietly lost, and other agents reading "done" on the task board will see status without the work behind it. The stop-hook will catch a missed commit, but catch it yourself first β€” and create a draft PR after the push if one doesn't exist yet (per the session prompt's GitHub Integration block). If something genuinely needs to wait for owner confirmation, say so explicitly in your reply and leave the working tree clean β€” stash, revert, or don't write it yet.

🚦 Operational signals β€” current state (mutable; flip only on the owner's say-so)

Live operational signals, separate from the permanent rules below. Each has a light, a date, and the owner's word. Read them every session. Don't change one unless the owner says so β€” and when you do, re-date it and quote the new instruction.

🚷 CRITICAL GUARDRAIL: DEPLOYMENTS ARE ON-DEMAND AND ON-COMMAND ONLY (owner policy, 2026-07-05)

To completely eliminate accidental, speculative, or unauthorized production deployments: 1. No Speculative Deploy Choices in Prompts: Agents must never offer or hardcode "deploy" options inside custom multiple-choice selectors, dropdowns, or ask_user panels. If a design option is presented, separate it entirely from deployment. 2. Explicit Standalone Command Only: A deployment can only be triggered if the owner explicitly issues a standalone instruction in the current turn containing a direct command (such as "deploy please" or "deploy now"). 3. No Multi-Turn or Blanket Consent: A single deployment command is strictly one-off. Consent from a previous turn or a general select option is never a green light to deploy subsequent commits. If a task is marked "Without deploying...", perform the task, commit, push, and STOP β€” wait for a separate, explicit deploy instruction.

🚦 Branch policy β€” READ THE TWO LIGHTS BELOW BEFORE YOU MERGE OR PUSH

πŸ“’ DEPLOYS GO TO VERCEL NOW β€” NOT THE NAS (as of 2026-06-29)

The app moved back to Vercel (off the self-hosted NAS). That's why main now carries a double light (double = Vercel Β· single = NAS β€” see the COUNT rule below). If you still assume "main β†’ NAS," that's stale: the deploy target is Vercel. The NAS pipeline is a manual fallback only.

β–Ά CURRENT SETTING (2026-06-29): main 🟑🟑 (double = VERCEL, manual) Β· nightly πŸ”΄ (paused)

β–Ά DO THIS NOW: commit/merge DIRECTLY INTO main ONLY β€” it deploys to Vercel, and deploys are

manual: a push does NOT trigger one (auto-deploy was turned off at the Vercel end on 2026-06-29). Commit, push, and move on β€” don't wait for or watch a build; when you actually want production updated, deploy it yourself: curl -X POST "$VERCEL_DEPLOY_HOOK". Do NOT push to nightly.

πŸ”€ MERGES GO THROUGH GITHUB, NEVER LOCALLY (owner instruction, 2026-07-01)

Land a branch on main by merging its pull request on GitHub β€” the PR Merge button, or the merge_pull_request API. Do NOT git checkout main && git merge <branch> + git push origin main from a local / agent clone. Push your work to a claude/** branch, open a PR, and merge that PR on GitHub so every change to main is a merged-PR record. (This governs the mechanism; the light table below still governs which branch and whether a deploy fires.) Source (verbatim, owner 2026-07-01): "no, merge to git, never locally. Mark that on AGENTS.MD please"

🧱 TYPE-CHECK / BUILD BEFORE YOU MERGE (owner-endorsed, 2026-07-02)

Before merging any code PR to main, run NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit (unfiltered) and confirm it's clean β€” a bare/filtered tsc --noEmit is NOT enough (it has missed implicit-any handler params and a miscased icon import that the real type-check caught). Since auto-deploy is OFF and deploys are manual, a main that doesn't type-check silently blocks every agent's production deploy until someone notices. Don't merge on a filtered/partial type-check. (A full next build runs that same type-check phase and is the truest gate β€” but it also runs env-dependent static generation, which fails in a credential-less sandbox at pages like /v/[id] [Bucket name not specified / no Firebase creds] for reasons unrelated to your diff. So: heavy unfiltered tsc is the reliable local gate; if you run next build, judge the "Checking validity of types" phase, not a later static-gen env error.) Run it with --incremental false AND on a fresh npm ci against the current lockfile β€” two 2026-07-16 deploys broke on errors the local gate missed: a stale .tsbuildinfo skipped rechecking a changed file (TS2448, T-167 P2 drawer), and a node_modules predating two weeks of lockfile changes made local TypeScript infer differently from Vercel's clean-room install (implicit-any, T-167 P3 panel). A gate on stale deps or a warm cache is not a gate. (Origin: the T-024 Airwallex drawer merged un-type-checking at 44add12 and had to be hot-fixed at 11f96e6 before main could deploy again.)*

The rule β€” every time you are about to merge or push: read the two lights on the CURRENT SETTING line above, find the matching row in the table, and do exactly what it says. The two lights are independent. Each branch is one of three states: 🟒 open + deploy (push here β€” and the push triggers a deployment), 🟑 open, NO‑deploy (push here exactly as 🟒, but the push triggers NO deployment β€” commit and move on; do not wait for, monitor, or expect a deploy; deploys are done **manually), or πŸ”΄ paused (do NOT push here). Don't assume; the setting changes, so re-read it each session.

Light COUNT = deploy TARGET (added 2026-06-29). Now that there are two deploy targets, a branch's light-group also encodes where it deploys: a double light (🟒🟒 / 🟑🟑) = Vercel, a single light (🟒 / 🟑) = the NAS (the original convention). The colour still means the state (🟒 auto-deploy on push Β· 🟑 commit, no auto-deploy / manual Β· πŸ”΄ paused). πŸ”΄ is always shown single β€” a paused branch deploys nowhere, so it carries no target (and we never signal deploying to both places). Combined examples: 🟒 = NAS, auto-deploy; 🟑 = NAS, manual; 🟒🟒 = Vercel, auto-deploy; 🟑🟑 = Vercel, manual. ⚠️ Read the count per NAMED branch β€” main 🟒🟒 means main β†’ Vercel, NOT "main 🟒 + nightly 🟒"; to keep that unambiguous, always write the target ("Vercel"/"NAS") in words next to the light-group. (main deploys to Vercel as of 2026-06-29 β€” hence its double light; the NAS is now only a manual fallback.)

main nightly What to do when you merge / push
🟑 πŸ”΄ main open but NO‑DEPLOY β€” β¬… TODAY'S SETTING. Commit/merge directly into main ONLY (same push rule as πŸŸ’πŸ”΄) β€” but the push triggers no deployment. Don't wait for or watch a build; deploys are manual. Do not push to nightly.
🟒 🟒 Lockstep. Merge your branch into nightly first, then bring main up to nightly (fast-forward main β†’ nightly) so both end at the same commit. Never leave one ahead of the other.
🟒 πŸ”΄ nightly paused. Merge your branch directly into main ONLY. Do not push to nightly. (Unlike 🟑, a 🟒 push does trigger a deploy.)
πŸ”΄ 🟒 main paused. Merge your branch into nightly ONLY. Do not push to main.
πŸ”΄ πŸ”΄ HALT. Both paused β€” ask the owner before pushing anything.
  • Source (current πŸŸ‘πŸ”΄ setting): owner instruction, 2026-06-29 (verbatim):

    "Since now I'm on the Pro Tier of Vercel, I don't want deployment to be triggered by commits anymore. Can you (if AGENTS.MD didn't state clearly,) give the green light on main a yellow light, and add (if not stated clearly) that it means, commit but don't trigger deployment. If it's not something that any agent can do, then can you keep main on yellow, so that the agent won't monitor for deployments, and make sure that auto-deploy per commit is off"

Made 🟑 true, not just documented: (1) nas-image.yml's push-trigger was removed (now workflow_dispatch-only) β€” a push to main no longer auto-builds/deploys to the NAS; (2) Vercel auto-deploy is off. Note the real lever: vercel.json's git.deploymentEnabled.main = false does not catch β€” what governs git-push deploys is the Vercel project setting gitProviderOptions.createDeployments. The 2026-06-29 GitHub↔Vercel reconnect left it enabled, so every push auto-deployed (3 verified); it's now set to disabled via the Vercel API, so a push to main (or any branch) no longer auto-deploys β€” only the deploy hook / API / dashboard button do. No branch commits were moved. - Source (target-light convention, 2026-06-29 follow-up): owner instruction (verbatim):

"Also, if you agree that this is necessary, please give each branch a double light (🟒🟒) for deploying to Vercel, and a single light (🟒 as it is now) for deploying to NAS please (and state that on the AGENTS.MD)"

Agreed β€” two deploy targets now, so the target should be explicit. Implemented as the COUNT = target rule above (double = Vercel Β· single = NAS; colour still = state), with the target spelled out in words next to each light so the count can't be misread as the two-branch line. - Source (Vercel-clarity emphasis + main β†’ double light, 2026-06-29): owner instruction (verbatim):

"some agents are still not aware that we have move things back to Vercel. Please indicate on AGENTS.MD in regards, if the double light and single light instruction isn't clear enough"

Acted on it: Vercel is live and deploying main (verified β€” 3 production deploys on 2026-06-29), so the single-NAS light was stale. Flipped main to a double 🟑🟑 = Vercel light and added the πŸ“’ banner at the top of this section so the target change can't be missed. Colour stays 🟑 = the owner's manual-deploy policy β€” now actually enforced: the owner chose to disable auto-deploy, so I set Vercel's gitProviderOptions.createDeployments = disabled (the vercel.json toggle never caught). A push to main no longer deploys; production updates go through the deploy hook. - Prior setting (superseded): πŸŸ’πŸ”΄, 2026-06-23 (owner: "Main green, nightly red. But no need to reverse and commits of the branches") β€” main open with auto-deploy ON via the NAS pipeline. Before that: 🟒🟒 LOCKSTEP, 2026-06-22. Flipped to πŸŸ‘πŸ”΄ on 2026-06-29 per the instruction quoted above. - To change a light: only the owner flips it. When they do, update the CURRENT SETTING line + the "TODAY'S SETTING" marker in the table, re-date, and quote the new instruction here.

(NAS preview p-eop dropped 2026-06-22 β€” see T-082; nightly is kept on standby but no longer serves the task-board build β€” see the "Task board β€” orphan branch taskboard" section below.)

πŸ“‹ Task board β€” orphan branch taskboard

β–Ά The task board at tasks.theestablishers.com publishes from the orphan branch taskboard β€” not from main and not from nightly.

taskboard has no shared history with main / nightly / claude/** β€” it's a separate branch graph that contains only the files the board build needs (docs/eop-tasks/, mkdocs.yml, requirements-docs.txt, scripts/build-docs-site.py, the deploy workflow, a README.md). The matching docs-deploy.yml is deliberately absent from web-app branches, so pushes to main / nightly / claude/** can never trigger a board build, and the board can never accidentally pick up web-app files.

Edit on your work branch first; mirror to taskboard second β€” every time you change a task or ticket. The main-side copy of docs/eop-tasks/ is the canonical record other agents read (the board only shows what's been mirrored). Editing only on taskboard makes the change invisible to anyone reading from main.

Recipe β€” use the helper. scripts/sync-taskboard.sh (on main) does the mirror via a sibling worktree:

# After committing your task/ticket edits on your work branch:
scripts/sync-taskboard.sh                  # auto commit message
scripts/sync-taskboard.sh "T-101 opened"   # custom suffix on the commit message
DRY_RUN=1 scripts/sync-taskboard.sh        # plan only, no push

What it does: ensures docs/eop-tasks/ is clean on your branch β†’ adds (or refreshes) a worktree at ../eop-taskboard checked out to taskboard β†’ hard-resets it to origin/taskboard β†’ rsyncs your docs/eop-tasks/ over (with --delete, so removed task files are removed on the board too) β†’ commits + pushes. The Cloudflare Pages build runs immediately on push.

⚠️ --delete cuts both ways β€” the mirror becomes exactly YOUR branch's docs/eop-tasks/. Any task another agent published to the board from a branch that is NOT merged into yours yet gets silently DELETED from the site by your sync. Before syncing, make sure your branch's docs/eop-tasks/ is a superset of the live board (when in doubt: git fetch origin taskboard and diff docs/eop-tasks/ against it; restore anything the diff shows as board-only before you push). Bitten 2026-08-10: a main-based sync dropped tasks published from an unmerged branch (T-201/T-202); the T-193 Helper caught and restored them β€” recorded here so the next agent doesn't repeat it.

Why orphan (not just a separate branch): complete isolation. A future cleanup that retires nightly doesn't touch the board; a future task-board format change doesn't entangle with web-app history. The branch graph stays understandable: each branch is its own little world.

Cloudflare side (one-time setup, owner action): in the eop-task-surface Pages project settings, set the production branch to taskboard so pushes deploy to the live URL. Other branches (if any are ever pushed) deploy to preview aliases.

Deploy mechanic (reference, not a signal)

⚠️ Auto-deploy per commit is OFF as of 2026-06-29 (main is 🟑). A push to main triggers no deployment β€” nas-image.yml's push-trigger is removed (workflow_dispatch-only) and vercel.json sets git.deploymentEnabled.main = false. Deploys are manual now: the owner is on Vercel Pro and triggers production deploys deliberately (Vercel dashboard "Deploy"; or a manual nas-image.yml dispatch as the NAS fallback). So commit + push, but don't expect, wait for, or watch a deploy.

Exception β€” Firestore rules DO auto-deploy on merge. The "nothing deploys on a commit" rule above is about the app. Firestore security rules are a separate pipeline: a push to main that changes any firestore.*.rules file (or firebase.json / .firebaserc) auto-triggers the deploy-firestore-rules.yml workflow, which runs firebase deploy --only firestore:rules (rules only β€” never indexes/functions/data). So a rules change lands in production on merge β€” you don't manually deploy rules separately, and you should expect this one to fire. (Vercel deploys only the Next.js app, not the rules; this workflow keeps the repo's firestore.*.rules from drifting out of sync with what's actually enforced in Firestore. Verify a run under GitHub β†’ Actions β†’ "Deploy Firestore rules".)

(Historical, pre-2026-06-29 β€” auto-deploy: pushing to the green target branch auto-triggered nas-image.yml β†’ GHCR β†’ the NAS auto-pulled + recreated eop-app. T-091 holds that mechanism + the eop-app-prev rollback runbook, retained for the manual NAS path. T-084 = the today-manual deploy path.)

Source: Accounting [Infrastructure Development] Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM β€” 2026-06-22

How to deploy (manual β€” nothing deploys on a commit anymore)

A push to a 🟑 branch deploys nothing. Deploy only on the owner's explicit order ("deploy main to Vercel" / "deploy now"). Prerequisite: the GitHub↔Vercel connection must be live (Vercel β†’ eop β†’ Settings β†’ Git); if a deploy 404s with "repository/branch not found," it's severed and only the owner can reconnect it (a GitHub-app OAuth flow β€” no API token can).

⚠️ IMPORTANT DEPLOY POLICY (one-off on-demand only): A yellow light (🟑) indicates that deployments are manual and on-command/on-demand. Any user instruction to deploy the web app is strictly one-off. It applies ONLY to that specific command and does not grant a blanket permission or "green light" to trigger deployments for subsequent commits or future tasks. Every individual deployment must be explicitly requested by the owner.

  • β†’ Vercel (double-light target) β€” any of:
  • Deploy hook (one call, no token): curl -X POST "$VERCEL_DEPLOY_HOOK" (URL in .env.local, also in GCP Secret Manager as VERCEL_DEPLOY_HOOK β€” how cloud agents fetch it, since the sandbox has no .env.local; the hook is created once in the dashboard β†’ Settings β†’ Git β†’ Deploy Hooks β†’ branch main β€” it is NOT API-creatable). Deploys main HEAD, so merge first, then fire the hook.
  • API: POST https://api.vercel.com/v13/deployments?teamId=team_ne7hiLb7J8wyHgGulNGIxGIz&forceNew=1 body {"name":"eop","project":"prj_6Y57ZalE8BdtcYU57lpuoXDQe953","target":"production","gitSource":{"type":"github","repoId":914151172,"ref":"main"}}, auth Bearer $VERCEL_API_TOKEN (in .env.local / GCP Secret Manager). vercel.json's deploymentEnabled.main:false blocks only push/auto deploys β€” a manual trigger still runs.
  • Dashboard: the project's Deploy button.
  • β†’ NAS (single-light target β€” manual fallback) β€” GitHub β†’ Actions β†’ "Build NAS app image" β†’ Run workflow (workflow_dispatch); builds + pushes to GHCR, the NAS auto-pulls.
  • β†’ Firestore security rules (separate from the app β€” auto-deploys on merge, see the "Exception" note above) β€” three ways:
  • Automatic (default, preferred): merge a change to any firestore.*.rules file (or firebase.json / .firebaserc) into main β†’ deploy-firestore-rules.yml deploys it. Nothing else to do.
  • Manual re-run (no code change): GitHub β†’ Actions β†’ "Deploy Firestore rules" β†’ Run workflow (workflow_dispatch).
  • CLI: firebase deploy --only firestore:rules --project aote-pms β€” reads firebase.json's per-database map (tebs-melβ†’firestore.tebs-mel.rules, tebs-erl→…, aote-system→…); unchanged files are a no-op, so deploying "all rules" only actually updates the file you edited.

Verify the live build afterward via the corner watermark + /deployments (T-108/T-109).

Working agreements

"The owner" = the human directing the current session β€” inferred from context, never the agent itself, and not a hardcoded name (different sessions may have different owners).

  • Plain language first; technical names in parens. When explaining a finding or proposing a fix, lead with what's happening and what would change in everyday words β€” short sentences, before/after framing. Cite file / function / field / task names inline so the owner can navigate, but don't let those names carry the explanation. Don't assume the owner remembers a past task by UID alone β€” summarise the prior decision in one plain sentence before naming it (e.g. "a previous change stopped writing the GL code on income transactions (T-075)", not "per T-075's GL_REVENUE_RESTRICTED invariant"). If the owner asks you to re-explain, that's the signal the earlier version was too technical; the next version must be more plain, not just shorter.
  • Phase wrap-up reports are written for the owner, not for the diff. When you report that a phase of work is finished, the owner is reading as an end-user without the technical background to know what a function name means or does β€” so a function name is not an explanation. The rule:
  • Lead with what the web app can now do that it couldn't before, described as what a person using it sees and does ("clicking a receipt notification now opens that receipt's details", not "ExpenseRecordsTab now consumes the open param").
  • A function / component / endpoint name may appear only after the behaviour it enables has been said in plain words β€” as a pointer for navigation, never as the substance.
  • Include the Firestore documents touched, each with its structure before β†’ after in the whole-document format the structure-change gate already requires β€” and when nothing structural changed, say so explicitly ("reads only" / "writes existing fields in their existing shape"), because silence reads as an oversight.
  • If the owner has to ask "but what does that actually do?", the report failed this rule β€” rewrite it plainer, don't just answer the question and move on. Source: owner instruction, 2026-07-28 (verbatim): "remain agents with their reporting style after they think they've finished a phase of work, to tell them not just mention function names without elaborating what it allows the web app to do as I don't understand function names and I don't know what they do as the end-user and a person without the technical knowledge of understanding so. (Along with Firestore structural explanation as previously requested)"
  • No duplicate tasks β€” match by scope, not UID. Before opening a new task, read the existing tasks (docs/eop-tasks/tasks/, published at tasks.theestablishers.com) and check whether any one's scope already covers your objective β€” a duplicate can hide behind a fresh UID, so compare the work, not the number. If a covering task exists, update/extend that task; never open a parallel task for the same thing. If you spot two existing tasks that already overlap, flag it and fold them.
  • Keep the task board current. Existing task β†’ update its T-NNN.md (frontmatter status + a dated progress note); after committing on your work branch, run scripts/sync-taskboard.sh to mirror the change to the orphan taskboard branch β€” that's what rebuilds the Cloudflare board (see the "Task board β€” orphan branch taskboard" section above). ⚠️ Flip a task's status in the SAME commit as the ship β€” "shipped on main" and status: doing must never coexist. When a deliverable merges, move its T-NNN.md status to done (with the close-out entry below) in that same commit β€” the code landing and the board saying so are one act, not two. Can't finish? Leave a dated note on what's left, not a silent stale doing. (The per-response attestation only reminds you about the task you're holding; a task you shipped and walked away from is caught by nothing but this rule.) Genuinely new work β†’ create T-NNN.md with the next free UID (docs/eop-tasks/README.md) + frontmatter, and bump the README.
  • UID collisions across parallel branches β€” renumber on merge, per PR. The README "next-free" pointer can't coordinate UIDs across branches that are open at the same time: two agents both read the same next-free and both allocate it, so the second to merge collides (the T-NNN.md / I-NNN.md file conflicts). Allocating is not reserving. The rule: whoever merges second renumbers their task/ticket to the real next-free at merge time (rebase against nightly first to see it). To renumber, move the whole identity in one commit β€” git mv the file + update its frontmatter uid/ tid, every [[T-NNN]]/related:/task: cross-link, any in-code [T-NNN] markers, and the README pointer β€” and add a dated renumber entry to the file's decision log explaining the collision. Commit messages and prior dated entries stay as-is (immutable history); only forward references move. Pick a number free past all known in-flight PRs, not just nightly, so you don't immediately re-collide.
  • Capture the why, not just the what. A task must carry the context a newcomer lacks β€” written whenever it becomes known (at the start, mid-work, or at done): why the task exists (the problem or owner concern behind it), and why it's built / routed the way it is when that isn't the obvious choice β€” name the reason: an owner concern, a web-app design constraint, or a physical / technical limitation that blocks the routing a reasonable person would expect. The aim is that nobody later asks "why didn't they just do X?" and undoes a deliberate decision.
  • Record decisions IN the file β€” attestation, approval, and QUOTED evidence. Whenever you create or update a task on the board, or make any non-trivial structural choice (Firestore/data shape, schema, wire format, "why it's built this way"), add a dated entry to that task file (or an ADR in docs/decisions/) containing: (a) a one-line attestation that you've read these guidelines, (b) what changed, (c) who proposed, (d) who approved (the owner), (e) the rationale, (f) the decisive conversation excerpts quoted verbatim β€” a conversation link isn't available, so quote the owner's exact words (attributed + dated); don't just paraphrase, and (g) a Source: line β€” Source: <agent name> Β· <session URL> β€” directly under the attestation, so cross-agent edits remain attributable independent of git authorship (every Claude agent commits under the same generic git author, so SHAs alone don't distinguish which agent wrote which entry). Per-response chat attestations intentionally omit the name + session URL β€” that lighter form is for chat only; task-board entries always carry the full Source: line. No structural change lands without this on record.
  • Every decision gets a line in the decision index β€” docs/eop-tasks/decisions.md. The task file holds the reasoning; the index holds a one-line pointer so a decision can be found later without knowing which task it lives in. Add the row in the same commit as the decision. When a decision is superseded, edit its row β€” a stale row is worse than no row.

What counts as a decision: a choice that constrains future work β€” something a later agent would have to undo or work around rather than simply extend. In practice, if any of these is true, index it: - it closed off a real alternative that was on the table (not "the only way it could work"); - it sets a rule or convention others must follow; - it touches data shape, money, or a document that leaves the company; - you would be annoyed to find it silently reversed by someone who didn't know why.

What is not: implementation detail with one sensible answer, naming, reaching for a library already used elsewhere, or a bug fix that restores intended behaviour. Those belong in the commit message.

Write the row so it carries its own context. Date Β· the decision as a single clause, in plain language, saying what was chosen β€” and where it isn't obvious, what it was chosen over Β· [[T-NNN]]. "Use gl for receipt links" is a bad row; "document-link information moves under gl, flat document-root fields are avoided" is a good one. If it won't fit in a clause, the decision is probably two decisions.

Never a transcript. The index is pointers; conversation logs are not published to the board β€” they can't be updated when a decision changes, and they preserve dead ends beside conclusions so a reader can't tell which is current. Source: owner instruction, 2026-07-25 β€” decision index adopted over storing the working conversation. - ⚠️ NEVER change a Firestore document's STRUCTURE without asking the owner first. This is a stop and ask rule, not a documentation habit: you present the change and wait for the owner's approval before writing the code. Data shapes outlive the task that introduced them, get read by pipelines nobody remembers, and are painful to unpick once documents exist in the wild β€” so the owner decides, not the agent.

What counts as a structure change: adding a field Β· removing one Β· renaming one Β· changing its type or nesting Β· changing whether something is stored vs derived at read time Β· introducing a new collection or document path. Reading existing fields does not require approval.

What to put in front of the owner β€” for every affected collection: - the path (payees/{abbreviation}/wopc/{docId}) and which database (aote-system, tebs-erl, …), since the same collection name can exist in more than one; - the CURRENT structure of the WHOLE DOCUMENT, as it exists in production today β€” not an excerpt of the part you intend to touch. Show every field and nested map, with types. This is the point of the rule: the owner is checking whether an existing field already does the job, or whether a new field is genuinely necessary, and neither question can be answered from a fragment. An agent proposing a new field has usually stopped looking once it decided to add one; - the structure AFTER, with each field marked + added, βˆ’ removed, ~ changed, unmarked when untouched β€” and show any new field in place, beneath its immediate parent, so its nesting and its siblings are visible rather than described; - the existing fields you considered and rejected, and why each doesn't fit. If you considered none, you have not done the work; - one plain sentence on why, and what breaks if it isn't done.

Source: owner instruction, 2026-07-25 (verbatim): "you can't just list out the affected field. Whenever the work gets to the modification of a Firestore docs, the agent must list out the entire structure of a doc… I need to see the current structure of the entire entry and the fields that you're going to add (including its immediate parent field) to see whether you're overlooking information that could be utilize to do the job that we're integrating or a new field or the editing of an existed field is truly necessary."

Ask this as its own question. Don't bury it inside a longer plan where a general "go ahead" gets read as schema approval β€” a broad approval is not approval for a schema change. If you discover the need mid-task, stop and ask rather than deciding it is small enough to slip in.

Group related fields into maps β€” do not lay a document out flat. Source: owner instruction, 2026-07-27 (verbatim): "I've been trying to avoid Firestore doc to lay fields flat". When you propose a new field, propose it under a parent that names what kind of fact it is β€” access.role, not a bare role; trail.sentAt, not a bare sentAt. A flat document forces every reader to know which of thirty sibling fields belong together; a grouped one states it. This is why the approval gate above insists a new field is shown beneath its immediate parent.

Concretely, when proposing a shape: - put a fact under the map for its kind β€” what was granted, what the person is, what happened and when β€” rather than beside unrelated facts; - a map with one field today is fine if the group is real and will grow; a map invented to hold one permanently-lonely field is not; - don't reuse a word at two levels with two meanings (a status map that also contains a status field reads as a bug in every review that follows); - existing flat documents are not a licence to add more flat fields β€” group the new ones and say plainly that the old ones stayed flat, rather than silently matching the worse pattern.

Once approved, record that same before/after in the task file so the shape can be reviewed later without re-reading the diff β€” and say explicitly when work touches Firestore but adds no fields ("creates documents in the existing shape, no schema change"), because silence there reads as an oversight. Source: owner instruction, 2026-07-25 (verbatim): "write on AGENTS.MD a reminder for all other agents as a general rule that agents must consult with me regarding modifications of the related Firestore docs of their structures with the listing out of the current structure and the structure after modification." - Cross-agent edits on shared task files: sign your work; never silently delete another agent's prose. Task files get read and updated by multiple agents over time. Two rules keep the trail honest, independent of git history: (a) Sign your additions. When you write into a paragraph that belongs to another agent's entry (e.g. inserting context, correcting a fact, appending a follow-up note), drop a Source (edit): <agent name> Β· <session URL> line under your insertion so the next reader can see who added what. Wholly new dated entries don't need this β€” the entry's own Source: line already covers them. (b) Strike-through, don't delete. If text needs removing, wrap it in ~~ … ~~ and append an italicised edit-out note carrying your Source: (*β€” superseded by <pointer>. Edit-out signed: <agent name> Β· <session URL>*). The struck text + reason stay readable; the history persists in the file itself rather than only in git, so an agent reading the task can see what was previously thought without scrolling commits. - Close the loop on done β€” final verdict + blast-radius handoff. Never flip a task to done silently. Append a closing entry with: (a) the outcome / verdict β€” what actually landed vs. the plan, and how it was verified; (b) every commit SHA that touched the task β€” an append-only running list (see the sub-bullet), not just the one that "finished" it; the diff lives in git, so don't re-narrate it; and (c) a blast-radius note for other agents β€” name the areas the change touches so an agent working nearby can tell whether it affects their task. The board is how agents stay aware of each other's changes; a silent done hides a blast radius that may break someone else's work. - done means MERGED, not DEPLOYED (owner policy, 2026-07-01). A task is eligible for done once its work is committed / merged to main β€” a production deploy is not a prerequisite (deploys are manual and decoupled from merge; see the branch-policy / deploy signals above). Don't park a finished task in doing just because it hasn't shipped to prod yet: flip it to done at merge, noting the deploy is still pending if it is (a data-only change is live the moment it's written; code awaits a deploy β€” the verdict just says which). - Record EVERY related SHA, not a single "finishing" commit (owner policy, 2026-07-01). A commit that claims to fix a task often doesn't β€” we've had to reopen and rework tasks many times β€” so a lone "finishing" hash is misleading. Instead keep an append-only list of every commit SHA that has to do with the task across its whole life: add each new one (work, fix, follow-up, re-open rework, and the doc/close commit) as it lands, and never delete earlier ones. Newest last. When a done task is reopened, keep the list and append the rework SHAs rather than trusting the old "done" hash as the final word. - Name the task UID in every commit message (owner policy, 2026-07-01). Every commit that does work for a task must include that task's T-NNN UID in its commit message β€” preferably in the subject line (e.g. feat(records): unify letterhead (T-124) or T-124: …). If one commit spans several tasks, name all the relevant UIDs (e.g. … (T-124) + … (T-125)). This is the rule that makes the every-related-SHA list above reliably derivable (grep the UID across git log) and lets reviewers trace commit ↔ task both ways; work commits that never named their task are exactly why the historical backfill could only be best-effort. (Same for tickets: cite I-NNN when a commit addresses one.)

Cloud-agent operations (cloud Claude Code only β€” local agents skip)

These rules apply specifically to cloud Claude Code agents (Anthropic-managed sandbox, fresh clone each run, only outbound HTTPS:443 allowed through Anthropic's TLS-MITM Egress Gateway). Local Claude Code running on the owner's machine has a different network model β€” these rules don't apply there.

  • NAS connection β†’ Cloudflare Tunnel, always. The sandbox's egress policy rules out direct SSH, WireGuard, OpenVPN, and Tailscale (its noise-protocol handshake gets rejected by the Egress Gateway β€” see docs/eop-tasks/runbooks/claude-nas-channel.md). The only path that survives is the outbound HTTPS Cloudflare Tunnel terminating at nas.theestablishers.com. The admin Cloudflare API token for managing / re-issuing tunnel credentials lives in the sandbox env (CLOUDFLARE_API_TOKEN + CLOUDFLARE_TOKEN_NAME). Default to the existing tunnel; only touch the token to fix tunnel issues, never as a routine credential. Local agents may use Tailscale instead β€” it works from a real machine; it doesn't work from this sandbox.
  • External credentials β†’ check the sandbox env FIRST, then GCP Secret Manager, then ask. Cloud Claude Code sessions get task-specific credentials injected straight into the sandbox env β€” Cloudflare API tokens, NAS / DSM creds for the Claude ↔ NAS channel, anything bootstrap-provisioned for the session β€” and those env vars are always the right first place to look because they're what the session bootstrap chose to expose. Don't waste a Secret Manager round trip on something that's already in env. The discovery order, when the owner asks you to connect to something external: (1) env β€” list keys (env -0 | tr '\0' '\n' | grep -E "^[A-Z_][A-Z0-9_]*=" | awk -F= '{print $1}' | sort -u) and scan for credentials that match the target (substring match on the service name, common suffixes like _TOKEN / _KEY / _SECRET / _PASSWORD). NAS / Cloudflare-Tunnel creds live here; never in Secret Manager. (2) GCP Secret Manager, when env didn't have it. The sandbox env carries a Google service- account credential triple (GOOGLE_PROJECT_ID + GOOGLE_CLIENT_EMAIL + GOOGLE_PRIVATE_KEY) with at minimum secretmanager.secretAccessor on the project (the SA inventory lives in docs/playbooks/sa-audit-context.md). List the project's secrets (gcloud secrets list --project="$GOOGLE_PROJECT_ID" or the @google-cloud/secret-manager SDK), match by name, try the candidates. App-runtime credentials (third-party API tokens, NextAuth secret, etc.) typically live here. (3) Ask the owner β€” only after both env and Secret Manager exhausted, with a concrete message: which env keys you matched on, which secrets you tried, why each failed. Never echo secret values into chat, commit messages, or any artifact pushed to the repo.
  • Owner's Google account β†’ claude.ai CONNECTORS, not API keys (documented 2026-07-22, owner request). The owner's personal Gmail / Google Calendar / Google Drive are reachable through claude.ai connectors (OAuth apps the owner installs on their claude.ai org), which surface to a session as MCP tools β€” there is NO Gmail/Calendar API key in env or Secret Manager, so don't hunt for one (claim history: briefly FALSE for Gmail on 2026-07-21–22 β€” a scoped DWD credential existed for the billing pipeline β€” then the owner rejected standing mailbox access and it was fully decommissioned the next day in favour of an owner-side Apps Script PUSH; see the "Billing emails" bullet below. The claim is TRUE again, now deliberately so. Note signed: Service Account (Local) Β· local session ca159e39-42a3-4569-b1cd-6e006f6639c3). How to use them: (a) Discover with the ListConnectors tool (filter e.g. ["gmail","google"]) β€” it reports each connector's install/auth state AND enabledInChat. A connector is usable only when both connected and enabled for the current chat; enablement is per-chat, so a connector another session used may still be off in yours. (b) You cannot complete OAuth from a session. If a connector is uninstalled, unauthenticated, or chat-disabled, tell the owner exactly which state it's in and ask them to fix it in their claude.ai connector settings β€” never ask for tokens/codes in chat. (c) State when documented: Google Drive connected + chat-enabled; Google Calendar connected, chat-disabled; Gmail installed, auth state unknown, chat-disabled. Re-check per session β€” don't trust this snapshot. (d) Reading the owner's alert emails usually doesn't need Gmail anyway: every alert the app emails (via notifySystemOwner β†’ Resend) is first written to the notifications collection in Firestore (aote-system DB) β€” query it with the env service-account (newest-first on createdAt) to see exactly what the owner was emailed, plus its read/cleared state.
  • Billing emails β†’ owner-side Apps Script PUSH; the app holds NO Gmail credential, by explicit owner decision (2026-07-22, verbatim: "I don't feel very safe for read-only access to my mailbox" β€” documented by Service Account (Local); supersedes the short-lived 2026-07-21 DWD pull architecture, which was fully decommissioned the next day).
  • Architecture: a Google Apps Script in the OWNER'S OWN account (canonical copy: docs/apps-script/billing-email-push.gs; install/ops notes in docs/apps-script/README.md) scans four narrow billing-email families (queries declared at the top of the script) and PUSHES each match to POST /api/workspace/billing/email-ingest, authed with WORKSPACE_INGEST_SECRET. Server-side parse/persist lives in lib/gcpBilling/emailBillingSync.server.ts (invoice PDFs β†’ Drive/File-Archive + per-line-item ledger charges; payment emails β†’ ledger payments; declines + GCP receipts β†’ event collections; auto-payments are INFERRED daily β€” invoice + no decline within 2 days β€” flagged inferred: true, bank-feed cross-checked).
  • Freshness: every push + a daily heartbeat stamps systemHealth/billing-email-sync; the scrape-heartbeat watchdog alerts if the script's trigger dies (36h). Remedy = check the script's Executions log + trigger + INGEST_SECRET in script.google.com (owner surface).
  • DO NOT re-create mailbox credentials. No Gmail API keys, no DWD grants, no service-account mailbox impersonation β€” the owner explicitly rejected that class of access. The gmail-invoice-fetcher@ SA still exists but is a keyless shell (its user key + Secret Manager copy + Vercel env were all deleted 2026-07-22); its now-inert DWD grant (client ID 115562755728083115762) is pending the owner's removal in admin.google.com. If a future task seems to need mailbox reads, extend the Apps Script's declared queries instead β€” and get the owner's explicit approval for any new family.
  • Updating that Apps Script's CODE β†’ scoped SA apps-script-manager@aote-pms (T-177), not copy-paste. The owner authorized scoped, attributable agent access to their Google account (2026-07-23: "not against agents having access to my account when the access is properly scoped"). A dedicated SA with zero IAM roles and a single DWD scope (https://www.googleapis.com/auth/script.projects β€” Apps Script code only) can push docs/apps-script/billing-email-push.gs via the Apps Script API projects.updateContent. Key is in Secret Manager APPS_SCRIPT_MANAGER_KEY (client ID 116845537588563115906). Hard boundary: this scope canNOT read mail/Drive or RUN the script β€” no Gmail scope was requested by design, so running runOnce/resetState and the script's mailbox authorization stay the owner's click. Edit the repo .gs β†’ agent pushes β†’ owner runs. Do NOT add scopes to this SA or reuse it for anything but Apps Script code; spin a new scoped SA for a new capability (the T-148 model). See T-177 for the pending owner setup (API toggle + DWD grant + scriptId).

Tickets β€” issues (I-NNN, I = Issue)

A lightweight ticket queue, separate from T-NNN tasks, for technical issues spotted in the app / real transaction data (e.g. "the display name renders wrong for certain transactions"). Tickets live in docs/eop-tasks/tickets/I-NNN.md, with their own next-free UID in tickets/README.md.

  • A ticket is the symptom. Capture what's wrong, the source (the tx / place it was found), repro notes, and status (open Β· investigating Β· escalated Β· fixed Β· closed Β· wontfix). Keep them light β€” observations, not a project plan.
  • Escalate structural / infra issues to a T-NNN task. If the root cause is structural β€” data shape, schema, routing, a cross-cutting fix β€” open a T-NNN task for the deliberate work on top of the ticket and cross-link both ways (ticket frontmatter task: T-NNN; the task body links [[I-NNN]]). The ticket stays the symptom record; the T-NNN task carries the full rigor (the why, decisions, and the close-the-loop verdict). A one-off data fix can stay ticket-only.

UI & display conventions

All field labels must appear on their own line followed by a colon, with the corresponding value rendered on the next line. Field titles use the Newsreader font in Extra Light weight, return strings use Newsreader in Medium weight, and window titles use Cantata One.

All date displays in the web app should use MMM DD, YYYY format unless otherwise specified.

Section headingsβ€”including page-dividing titles such as "Personal Information", "Contact Information", "Payment Information", and "Billing Information"β€”and table headings use the Cantata One font. Table row content uses the Newsreader font at weight 500.

Any empty strings or missing data fields retrieved from Firestore should never cause the web app to become unresponsive. Empty string, null, or undefined values must render as N/A, and retrieval failures as Error. When a numeric or date value is unavailable simply display a dash (-).

Date fields must be validated before calling .toLocaleDateString() or similar methods. Invalid or empty values should be ignored, and the UI should show a placeholder rather than throwing errors or becoming stuck.

Coaching Sessions Page Fonts

The coaching sessions dashboard (pages/dashboard/businesses/coaching.tsx) and its dialog tabs follow these typography rules:

  • Floating window headers in the student dialog use Cantata One.
  • Field labels across OverviewTab, SessionsTab, BillingTab, PersonalTab, and SessionDetail render in Newsreader at weightβ€―200.
  • Corresponding field values in those tabs render in Newsreader at weightβ€―500.

Debug Notes

The student dialog spinner persisted because Vercel served an outdated bundle that lacked the latest loading-flag resets. Redeploying and hard-refreshing the browser resolved the issue. Version logs (=== StudentDialog loaded version 1.1 ===) remain temporarily to confirm deployments.

Later we discovered the dialog could still hang when non-active tabs were not mounted. Conditional rendering prevented PersonalTab, SessionsTab, and BillingTab from firing their data-fetch effects, so the parent never cleared its loading flags. Always render all tabs and toggle visibility with CSS so their callbacks run and the spinner disappears.

Another hang arose when the initial spinner replaced the entire tab layout. With the tabs unrendered, their effects never ran and the loading flags stayed true. The dialog now overlays the spinner while keeping all tabs mounted so those callbacks always clear the flags.

Continuous reloads later surfaced when OverviewTab passed inline callbacks to the child tabs. Each render created new onPersonal, onBilling, and onSummary functions, triggering the children’s useEffect hooks repeatedly and re-fetching data in a loop. Memoizing these handlers with useCallback stabilised their references and stopped the dialog from constantly refreshing.

The most recent reload loop traced to defining the error boundary inside OverviewTab. Because the boundary class was re-created on every render, React unmounted and remounted the entire dialog tree, resetting all loading flags and re-triggering data fetches. Moving the boundary to the module scope keeps its identity stable and prevents the dialog from restarting after each render.

Floating windows later became immovable when a onMouseDown handler on the header stopped drag events from reaching react-rnd. Removing that handler restored independent window movement.

  • All date displays in the web app use MMM DD, YYYY format.
  • Section and table headings use Cantata One font; table row content uses Newsreader font at weightβ€―500.
  • All modals must provide a Back/Close navigation consistent with the Session Detail modal.
  • Tab and sub-tab navigation lives in the dialog sidebar. The Billing parent tab shows the summary; its only child sub-tabs are Retainers and Payment History. Sub-tabs must not appear in top bars or popovers.
  • Retainer status colors: green for active, red for expiring/expired, lightBlue for upcoming, and lightGreen when an expired retainer has a future one scheduled.
  • Floating window titles use Nunito.
  • Selected Payment Detail shows curated labels: Payment Amount, Payment Made On (date only), For session.
  • Retainer end date is the day before the same day next month (end at 23:59:59).