chore: salvage unmerged work from the 2026-07-16 worktree audit (#1043)

* chore: salvage unmerged work from the 2026-07-16 worktree audit

Four items survived the 43-file dirty-tree audit as genuinely unmerged:

- CLAUDE.md: Definition of Done rule 9, the last mile is verified
  in-session, not assumed (project-level counterpart of the switch-on
  check; cloud agents only see the repo file).
- DECISIONS.md: eight decision lines from 2026-07-09 to 2026-07-15,
  condensed and scrubbed of production identifiers for the public repo.
- .claude/skills/loop-ignite: skill that audits the agentic loops and
  ignites dead ones; must live on main for cloud routines to load it.
- lib/bokslut/ixbrl testbank manual E2E: encodes the working testbank
  endpoints and the Luhn-valid test pnr (the documented one fails);
  skipped unless BOLAGSVERKET_TESTBANK_E2E=1, so zero CI cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: address review findings on the salvage batch

- testbank e2e: kontrollera returns HTTP 200 even for invalid documents
  (outcome is in utfall), so assert zero typ='error' entries; also assert
  grunduppgifter returns a company name, not just the echoed orgnr.
- loop-ignite: make ignition explicitly idempotent (enable/repair an
  existing trigger before creating, never duplicate).

Skipped the fourth finding (require an observed firing as switch-on
proof): a just-created cron cannot have fired yet; the audit table
already reports last observed run per loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-17 13:35:55 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5b8e3fa130
commit fd1c266cb0
4 changed files with 113 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: loop-ignite
description: Verify the agentic loops are actually running and ignite any that are not. Use at the start of any session, or whenever the user manually asks to "check the pr comments" / check logs / triage issues, which signals a loop is not firing. The loops exist (dev_docs/loops.md); the historical failure mode is that nobody initiates them.
---
# loop-ignite
**Goal:** the user never has to type "check the pr comments" again. That prompt was typed 323 times
between 2026-04 and 2026-07; the loops that replace it were built 2026-07-01 and never initiated.
This skill closes that gap.
## When invoked
1. **Audit what is live.**
- Cloud routines: list scheduled triggers (`/schedule` or the list-triggers MCP tool). Expected per
`dev_docs/loops.md`: PR+CI triage `0 7,11,15 * * *` UTC, Vercel errors `0 6 * * *` UTC,
issue triage `0 7,15 * * *` UTC.
- Local fallback: `CronList` for local scheduled jobs.
- Evidence of firing: recent `loop/*` branches, PR comments by the loop, `loop:needs-human` labels
(`gh pr list`, `gh issue list --label loop:needs-human`).
2. **Report a one-screen status table**: loop, expected cadence, last observed run, verdict (LIVE / DEAD / NEVER RAN).
3. **Ignite what is dead.** For each non-live loop, in order of preference (idempotent: re-list
triggers first and enable/repair an existing one before creating; never create a duplicate):
- Re-create/enable its cloud routine trigger.
- If cloud is unavailable, schedule a local fallback (`CronCreate` invoking the matching
`/loop-*` skill) and say clearly that it only runs while this machine is awake.
- If neither is possible, run the loop skill once now AND file the blocker as a GitHub issue so
the gap is visible instead of silent.
4. **Switch-on check (mandatory):** after igniting, verify the trigger exists by listing it again.
End with either "ALL LOOPS LIVE" or "NOT SWITCHED ON YET: <loop> - <what remains, who flips it>".
## If the user manually types a loop-shaped request
("check the pr comments", "check the vercel/supabase logs for errors", "triage the issues")
Do the requested work, then ALSO run the audit above and tell the user which loop should have made
the request unnecessary, and whether it is now live.
+1
View File
@@ -48,6 +48,7 @@ A change is done when all of these hold; iterate until they do:
6. `npm run check:guards` passes if you touched API routes.
7. Commit is conventional (`feat:`/`fix:`/`refactor:`/`test:`/`docs:`), atomic, branched from `main`.
8. If the change touches migrations, local and prod are reconciled: every version in prod's `schema_migrations` has a matching file in `supabase/migrations/`, and vice versa. Check before opening the PR (e.g. `list_migrations` / `select version from supabase_migrations.schema_migrations`); a remote-only version means an uncommitted orphan that will fail the merge.
9. **The last mile is verified in-session, not assumed.** Whatever was built is confirmed switched on before the session ends: merged PR's migration applied to prod, scheduled loop/routine observed firing, script actually executed, feature reachable. If switch-on must wait, the session's final output states exactly what is NOT live yet and who flips it. History shows the expensive failure mode is built-but-never-initiated, not built-wrong.
## Commands
+8
View File
@@ -192,3 +192,11 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator.
[2026-07-17] YearEndPreview.netResult now derived from the closing-lines totals (the signed 2099/2010 transfer) instead of generateIncomeStatement: the income statement excludes source_type='year_end' entries, so bokslut-flow depreciation/dispositioner were missing from the preview summary card while the bokslutsverifikation table included them (issue #766); patching the income statement instead was rejected because its exclusion is load-bearing (post-closing RR would collapse to zero) and executeYearEndClosing never reads netResult.
[2026-07-17] Issue #310 vat_rate normalizer lives in lib/vat/supplier-invoice-line-checks.ts (shared by MCP staging and the pending-operation executor) instead of a local helper in mcp-server/server.ts: core cannot import extensions, and the legal-rate set (LEGAL_VAT_RATES) already lives there; duplicating the statutory set in two files invites drift.
[2026-07-09] Billing checkout/portal guard BOTH is_anonymous and is_sandbox: a sandbox-company user created a live Stripe customer via POST /api/billing/checkout (no subscription, so never charged) because neither route checked either flag and withRouteContext admits anonymous users. Both conditions are checked even though they co-occur today (they are orthogonal), the anon check runs first (no DB round trip), and GET /api/billing/status exposes isDemo so the client hides the upgrade CTA instead of showing a button that 403s. One orphan Stripe customer and its company_subscriptions row left for manual cleanup (external destructive action, not done unilaterally).
[2026-07-09] Wrong-underlag-after-SIE-migration support case (143 wrong files on one company's imported year): recommended the EXISTING replace-import flow (web /import duplicate detection, "Ersatt befintlig import") over building bulk document-version replace or 143 per-voucher swaps; verified the whole path live on prod first (undo/replace RPCs wired, customer's period open with zero non-import entries). Undo/replace detaches documents (journal_entry_id=NULL) so the wrong files become deletable per deleteDocument's linked-only guard: the documented legal carve-out for migration imports, not a breach of never-delete. Deliberately NOT built now: an agent-side replace-document-version MCP tool (promised in the support thread, needs its own issue).
[2026-07-09] Consolidated the company-data-quality dev_docs into one master, dev_docs/data_quality_master.md: bank_transaction_ai_normalization.md folded in as Appendix B, ledger_context_resource.md's live parts as Appendix F, both plus mcp_optimization_plan.md archived under dev_docs/archive/. Why: three docs described one program from different ends and had begun double-tracking. Open question flagged in master section 7: Item B Layer D wants an LLM adjudicator plus embeddings, which contradicted the then-standing no-LLM decision (revisited 2026-07-10, see below).
[2026-07-10] FX fallback-rate poisoning (pre-#892) repaired for UNBOOKED transactions only via scripts/repair-fx-fallback-rates.ts (idempotent, rate-guarded); the 68 BOOKED rows are deliberately NOT auto-corrected because correcting them means storno/correction vouchers in customer books, a per-company decision (worst single-company overstatement roughly 6.7k SEK). Also deleted one dangling seeded demo document and its inbox row that made the nightly documents-verify cron fail; the seed fix in PR #965 prevents recurrence.
[2026-07-10] Preview-deploy Supabase env: widened NEXT_PUBLIC_SUPABASE_URL/ANON_KEY across the whole Vercel Preview environment (pointed at the staging branch project) instead of the Supabase Vercel integration's per-PR branch sync: per-PR branches cost money and reintroduce orphan-migration merge friction.
[2026-07-10] Reopened the no-LLM call for account intelligence (founder directive): dev_docs/data_quality_master.md v2.1 revises D6. AI is scoped to where the deterministic engine has zero signal (supplier cold-start classification, no-hard-key residue, fleet-cluster naming) under D8 guardrails: persist-first (no model calls on booking hot paths), approval-gated suggest-band only (never preselect, never auto-book), AI priors in a sidecar table not categorization_templates, reuse the existing eu-north-1 Bedrock client (BFL residency), self-hosted/no-creds degrades to deterministic. The deterministic floor still ships first in every workstream; the AI-subsystem removal migration stays valid for what it removed (the auto-booking agent), not for AI as such.
[2026-07-12] Issue #988 (transaction stuck on a removed verifikat): root cause was predicate divergence, the re-booking guards treated ANY non-null transactions.journal_entry_id as linked while the UI counts only status='posted'. Fix is a shared hasLiveJournalEntryLink() so stale pointers (reversed/cancelled/missing) are re-linkable, failing CLOSED on read errors, plus optimistic locks on the exact previous pointer instead of always .is(null). Chose guard self-heal over a null-on-reverse DB trigger because a trigger would fight correctEntry's re-point-to-correction behavior and race the correction insert; reverseEntry/correctEntry re-links stay best-effort. match-invoice/match-supplier-invoice untouched (they key on invoice ids; prod had zero dangling there).
[2026-07-15] Issue #735 result-appropriation: did NOT run the mass 2099-to-2098 backfill. Prod audit showed the script read the FROZEN opening-balance 2099, not the CURRENT posted balance: 202 of 360 planned periods already carried their own disposition (mostly via SIE import) and 43 would have double-moved equity (worst case tens of MSEK on a single company), while periods without an OB entry fabricated balances via the cumulative-history fallback. Posted ONE verified entry for the reporting company (unblocking their arsredovisning) and rewrote the script to a current-balance-safe backfill: eligibility decided from current posted 2099, explicit OB entry required, already-disposed and ambiguous periods skipped to a manual-review report. planResultAppropriation/generateResultAppropriation untouched (steady-state year-end path, covered by pg-real tests); the safety lives in the sweep.
@@ -0,0 +1,68 @@
/**
* Manual E2E against Bolagsverket's testbänk (static test data). Skipped
* unless BOLAGSVERKET_TESTBANK_E2E=1: requires the firewall opening for our
* public IP (ordered via api@bolagsverket.se, confirmed 2026-06-18).
*
* Run: BOLAGSVERKET_TESTBANK_E2E=1 npx vitest run lib/bokslut/ixbrl/__tests__/testbank-e2e.manual.test.ts
*/
import { describe, expect, it } from 'vitest'
import { generateK2IxbrlDocument } from '../document/k2-document'
import { makeInput } from './fixtures'
const BASE = 'https://api-accept2.bolagsverket.se/testapi'
// Static test env only accepts these (ANSLUTNINGSANVISNING §3.1); the pnr
// must match (19|20)\d{10} and pass Luhn: GUIDE's documented 190001010106
// fails Luhn, 190001010107 passes and is accepted.
const PNR = '190001010107'
const ORGNR = '1234567890'
const enabled = process.env.BOLAGSVERKET_TESTBANK_E2E === '1'
async function post(path: string, body: unknown) {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const text = await res.text()
return { status: res.status, json: JSON.parse(text) as Record<string, unknown> }
}
describe.skipIf(!enabled)('Bolagsverket testbänk E2E', () => {
it('grunduppgifter returns the static test company', async () => {
const res = await fetch(`${BASE}/hamta-arsredovisningsinformation/v1.4/grunduppgifter/${ORGNR}`)
expect(res.status).toBe(200)
const json = (await res.json()) as { orgnr: string; namn: string }
expect(json.orgnr).toBe(ORGNR)
expect(json.namn).toBeTruthy()
console.log('grunduppgifter:', json.namn)
})
it('skapa-inlamningtoken then kontrollera accepts our generated K2 document', async () => {
const input = makeInput()
input.company.orgNumber = '123456-7890'
input.company.name = 'Aktiebolaget Specifik Konsult'
const { xhtml } = generateK2IxbrlDocument(input)
console.log(`generated iXBRL: ${(xhtml.length / 1024).toFixed(1)} KiB`)
const tokenRes = await post('/lamna-in-arsredovisning/v2.1/skapa-inlamningtoken/', {
pnr: PNR,
orgnr: ORGNR,
})
expect(tokenRes.status).toBe(200)
const token = tokenRes.json.token as string
expect(token).toBeTruthy()
const kontrollRes = await post(`/lamna-in-arsredovisning/v2.1/kontrollera/${token}`, {
handling: { fil: Buffer.from(xhtml, 'utf8').toString('base64'), typ: 'arsredovisning_komplett' },
})
console.log('kontrollera status:', kontrollRes.status)
console.log(JSON.stringify(kontrollRes.json, null, 2))
expect(kontrollRes.status).toBe(200)
// kontrollera answers 200 even for invalid documents; the validation
// outcome lives in utfall, so acceptance means zero error entries.
expect(kontrollRes.json.utfall).toEqual(
expect.not.arrayContaining([expect.objectContaining({ typ: 'error' })]),
)
}, 120_000)
})