feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint (#1758)

* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector'

Sovereign plan WS3 PR3: ships dark, nothing changes for hosted.

- lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync,
  skatteverket, org_lookup, migration (services Accounted operates that a
  self-hosted instance cannot provide itself) + isConnectorCapability().
  Separate from PAID_CAPABILITIES and outside the trial-seed trigger on
  purpose: a hosted company can never hold a connector grant.
- lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key).
  Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins,
  else the grant lookup). Self-host: local capabilities always on
  (FORCE_PAYWALL included, as the existing test demands); connector
  capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the
  grant lookup where the connector sync will write source='connector' rows.
  getCompanyEntitlements on a self-host: local paid keys + active connector
  keys, state 'paid' with an active connector grant else 'none' (never the
  hosted trial copy).
- Migration 20260820122000: capability_grants.source CHECK gains
  'connector', found through pg_constraint (the CHECK was declared inline
  and auto-named; Postgres stores IN as = ANY, matched accordingly).
  pg-real test: connector accepted, unknown source rejected, upsert on the
  (scope, key, source) identity, trial seed writes no connector rows.
- Tests: self-hosted connector matrix (local all-on without DB, connector
  gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector
  keys only, bulk resolution, entitlements shape); two pre-existing tests
  that asserted the old "self-host holds connector keys" contract updated
  to the new one.

Verified: full unit suite green, pg-real suite for lib/entitlements green
against a local supabase/postgres with every migration applied, lint
ratchet, guards. Deferred to the instance-wiring PR: adding the connector
extensions to the self-host Docker preset (dead-end upsells until a key can
be issued).

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

* refactor(entitlements): fold the self-host branch into the existing grants query

One .or(scopeFilter), not two: the duplicated helper pushed the
no-phantom-columns unresolvable-expression count to 380/379. Behaviour is
unchanged; the self-host matrix tests still pass.

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

* feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly

Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the
entitlement partition (#1747). Nothing is purchasable yet; this is the
plumbing both ends need before the first manually issued key.

Hosted side:
- Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix,
  org_number, pinned instance_url, scopes, status, Stripe ids,
  current_period_end, per-minute rate limit, active_company_count,
  last_seen/synced) and connector_usage_events (per-request metering,
  separate from metered_events whose company_id references hosted
  companies). RLS on, NO policies: service role only. RPC
  validate_and_increment_connector_key copies the api_keys pattern (FOR
  UPDATE, minute window, suspended reported not counted, revoked = no row)
  and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role.
  pg-real test covers validate/count, unknown+revoked, suspended, rate
  limit, execute privileges per role, RLS invisibility, usage cascade.
- lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts
  (generate/hash/validate -> 401/403/429 mapping),
  with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per
  request, 500 envelope on handler throw), /api/connect/entitlements GET +
  POST (records active_company_count, pins instance_url on first report,
  never moves a pinned one), scripts/issue-connector-key.ts (dry run unless
  --confirm, prints the key once + the .env lines).

Instance side:
- lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL
  default https://app.gnubok.se), sync.ts: reports the active company count
  and writes source='connector' grants for every company x covered scope,
  expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status
  deletes them (freeze-and-retain); network/5xx/429 leave them alone.
  /api/connector/sync/cron (hourly) runs it; not_configured without a key.
- Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json,
  with reasons) + drift tests; docker/crontab.self-hosted regenerated with
  the hourly sync. Docs (SELF-HOSTING connector section, env templates),
  DECISIONS.

Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes
and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests
run locally against supabase/postgres with every migration applied.
no-phantom-columns ceiling +1 with a reason (the bulk grant upsert).

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

* feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget

Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A
self-hosted instance with a `bank_sync`-scoped connector key can now connect
a bank through Arcim's PSD2 credentials; the bank session id and all
transaction data stay in the instance's own database (founder decision:
tokens on the instance, proxy stateless).

- Migration 20260820124000: `connector_connections` (secret-free ledger:
  sha256 of the EB session id + account uids, service-role only),
  `connector_upstream_counters` + RPC `connector_reserve_upstream` (global
  budget under EB Annex 1 §5's 300/min, shared with hosted), and
  `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs
  REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real
  covers all of it.
- EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core
  must not import @/extensions/); the extension re-exports it, tests
  unchanged.
- lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed
  connector state (15-min TTL) so the consent redirect can use OUR
  registered EB callback and bounce back to the instance, no per-instance
  redirect URI at EB; the callback route gains that connector branch.
- app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions,
  accounts/{uid}/{balances,transactions}), never open passthrough. POST
  /auth enforces the per-company connection quota + rewrites redirect/state;
  reads/deletes verify ledger ownership; every upstream call takes the
  global budget (429 + Retry-After when exhausted).
- issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of
  v1), --bank/skv-connections-per-company + --sync-min-interval.
- Docs (SELF-HOSTING: bank connector live), DECISIONS.

Verified: 52 connect unit tests + 13 pg-real (run locally against
supabase/postgres with all migrations) + EB extension suite (225, jwt
relocation intact); full unit suite 15 979 green; tsc, guards, lint clean.
Not in this PR: SKV broker (PR5b) and instance wiring (PR6).

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

* feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance)

Sovereign plan WS3 PR5b, stacked on the bank proxy (#1751). A self-hosted
instance with a `skatteverket`-scoped connector key can now run the BankID
consent, file VAT/AGI and sync skattekonto through Arcim's registered
Skatteverket client; the SKV tokens are returned to the instance and stored
(encrypted) there.

- lib/connect/upstreams/skatteverket-oauth.ts: core-side SKV OAuth + data
  helpers (authorize URL, code/refresh exchange with Arcim's client secret,
  the four backing-API base URLs, the API-gateway Client_Id/Client_Secret
  headers). Core can't import @/extensions/, so this duplicates the
  extension's endpoints/scope set (one integrator = Arcim), mirroring the EB
  JWT relocation.
- app/api/connect/skv/oauth/authorize-url: builds the authorize URL against
  OUR registered redirect_uri + a signed connector state, per-company SKV
  connection quota, pending ledger row.
- app/api/connect/skv/oauth/token: exchanges/refreshes and RETURNS the tokens
  to the instance; the ledger keeps only sha256(access_token) +
  sha256(refresh_token).
- app/api/connect/skv/api/[...path]: allowlist over moms / skattekonto /
  agd-inlamning / agd-period. The instance sends the user's SKV Bearer (as
  X-Connector-Upstream-Authorization) + X-Connector-Key; the proxy checks the
  token hash against the ledger, adds Arcim's gateway credentials (never
  exposed to the instance), forwards. Same per-key + global budget as bank.
- The Skatteverket extension /callback gains the connector branch
  (isConnectorState -> 302 back to the instance; code never exchanged there).
- Docs (SELF-HOSTING: SKV connector live) + DECISIONS.

Tests: SKV oauth lib, authorize-url, token, data proxy, callback connector
branch (all green; 74 connect + 425 connect/SKV). tsc, guards, lint clean;
no-phantom-columns held at 380 (literal update branches). Not in this PR:
instance-side wiring (PR6).

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

* feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint

Sovereign plan WS3 PR6 (enablement layer), stacked on the SKV broker (#1757).

- docker/extensions.self-hosted.json += enable-banking, skatteverket: a
  connector-key self-host now ships the bank + Skatteverket extensions; a key
  with the matching scope makes them work, without one they show the existing
  capability_blocked upsell (unconfigured extensions no-op).
- lib/connect/instance/upstreams.ts: the connector-mode seam. An upstream is
  in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has
  no own credentials for it (hasOwnEnableBankingCredentials /
  hasOwnSkatteverketCredentials). Hosted always has own credentials, so hosted
  is provably never in connector mode: the guard is what keeps hosted
  byte-identical. Base URLs GNUBOK_CONNECT_URL/api/connect/{bank,skv}, headers
  X-Connector-Company / X-Connector-Upstream-Authorization.
- GET /api/connector/status: the operator's wiring view (self_hosted, per
  upstream own_credentials|connector|unconfigured, key prefix never the key,
  granted connector capabilities). Hosted returns self_hosted:false.
- Docs (SELF-HOSTING: status endpoint + extensions ship in the image),
  DECISIONS.

Tests: connector-mode detection matrix (off without a key, off with own
creds incl. the _PRODUCTION EB variants, on via the proxy, CONNECT_URL
override) + status route (self-host vs hosted, unconfigured, per-upstream
mode, prefix-not-key). 83 connect/connector tests green; tsc, guards, lint.

DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify
end to end, touches the live consent path): wiring the EB api-client /
consent callback and the SKV oauth / api-client to call the proxy in
connector mode, and the "Synka nu" settings row (UI, needs visual sign-off).
The seam + preset + status route make PR6b a contained follow-up.

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

* refactor(connect): upstreams seam reuses lib/entitlements/own-credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* test(connector): status route tests pass the Next params argument (post-merge withRouteContext signature)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* docs(self-host): collapse the re-duplicated connector section; correct the crontab generator's preset comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* fix(self-host): UpgradeNote and SKV tooltip name the connector key, never the hosted subscription; SOVEREIGN.md updated to merged reality

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* fix(self-host): BankSyncNowButton gate copy branches like UpgradeNote (connector key, not hosted billing)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-08-31 23:18:43 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg Emil
parent b643e6ce64
commit 50f13cf198
12 changed files with 287 additions and 20 deletions
+4
View File
@@ -1143,6 +1143,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES and outside the trial-seed trigger, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
[2026-08-20] Connector-key infrastructure (plan WS3 PR4) ships the hosted registry + the instance sync, nothing a customer can buy yet: connector_keys / connector_usage_events are service-role-only tables (RLS on, no policies) with an atomic validate_and_increment_connector_key RPC that copies the api_keys pattern (SHA-256 at rest, FOR UPDATE row lock, per-minute window) and is REVOKEd from PUBLIC, anon and authenticated before anyone can call it (the SECURITY DEFINER exposure lesson applied up front); /api/connect/entitlements behind withConnectorAuth (Bearer or X-Connector-Key, 401/403/429, one usage row per request); keys issued by scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once). The instance side writes source='connector' grants expiring at min(now+72h, period_end+3d) on every hourly sync, deletes them on 401/403 or a non-active status, and leaves them alone on network/5xx errors: the grant rows ARE the offline cache, no new cache code. The hourly job lives only in docker/crontab.self-hosted through a new EXTRA_JOBS table in the crontab generator (with its own drift tests), because vercel.json is the hosted schedule and hosted has no connector key. connector_usage_events is a separate table because metered_events.company_id references hosted companies and a connector key belongs to an instance, not a company here. Deferred: the proxy routes (bank/skv/org/migration: founder legal check with Enable Banking/SKV/TIC is the launch blocker), a connect.gnubok.se host rewrite (the instance calls app.gnubok.se/api/connect directly; a dedicated host is a later DNS decision), the self-host Docker preset change and the settings row.
[2026-08-20] Connector bank proxy (plan WS3 PR5a): app/api/connect/bank/[...path] brokers Enable Banking for self-hosted instances with tokens staying on the instance (founder decision) and the proxy stateless apart from a secret-free connection ledger. Design that keeps EB Annex 1 §3/§7 satisfied: the instance never holds the EB JWT (minting moved to lib/connect/upstreams/enable-banking-jwt.ts so core does not import @/extensions/; the extension re-exports it); the consent redirect goes to OUR already-registered EB callback, which detects an HMAC-signed connector state (lib/connect/hosted/state.ts, 15-min TTL, CONNECTOR_STATE_SECRET or a one-way derivation of the service-role key) and 302s the browser back to the instance, so no per-instance redirect URI is registered at EB. Ownership: connector_connections ledger stores sha256(session_id) and the account uids, never the session; GET/DELETE /sessions and /accounts/{uid} calls verify the handle/account belongs to the presenting key. Quotas: per-company bank connection limit (connector_keys.limits, sold in the package, checked at POST /auth), per-key RPM (validate RPC), and a GLOBAL budget (connector_reserve_upstream RPC, connector_upstream_counters, ~30% of EB's 300/min so hosted is never starved; fail-open on a counter error). validate_and_increment_connector_key gained a limits column (v2). issue-connector-key.ts scopes default to bank_sync,skatteverket (TIC/org_lookup out of v1 per founder) and take --bank-connections-per-company etc. Path allowlist only, never an open passthrough. Deferred: the SKV broker (PR5b, same pattern, callback branch on the SKV extension) and the instance-side wiring (PR6: EB client connector-mode branch, self-host preset, settings row).
[2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so app/api/connect/skv/* does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api/<service>/<path> is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380.
[2026-08-20] Connector instance enablement (plan WS3 PR6): the self-host Docker preset (docker/extensions.self-hosted.json) now includes enable-banking + skatteverket, so a connector-key self-host gets working bank/SKV instead of hidden extensions; without a key they show the existing capability_blocked upsell (unconfigured extensions no-op). lib/connect/instance/upstreams.ts is the connector-mode seam: an upstream is in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has no own credentials for it (hasOwnEnableBankingCredentials / hasOwnSkatteverketCredentials), which is what keeps hosted byte-identical (hosted always has own creds). GET /api/connector/status is the operator's wiring view (self_hosted flag, per-upstream own_credentials|connector|unconfigured, key prefix never the key, granted connector capabilities). DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify end-to-end, and touches the live EB/SKV consent path): wiring the EB api-client/consent-callback and the SKV oauth/api-client to actually call the proxy in connector mode (base-URL + header swap for data reads; redirect_url=instance callback + X-Connector-Company + connector_state round-trip for consent), plus the "Synka nu" settings row (UI, needs visual sign-off). The seam, preset and status route make PR6b a contained follow-up.
[2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so `app/api/connect/skv/*` does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api/<service>/<path> is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380.
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES; the trial-seed trigger does seed 30-day source='trial' rows for bank_sync/skatteverket (they are PAID keys) but never writes source='connector' and never seeds the connector-only keys (org_lookup, migration), and on a self-host only source='connector' rows unlock a connector capability, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
[2026-08-20] Sovereign package docs (plan WS2 PR1): docs/SOVEREIGN.md is written as regulatory-risk elimination with a per-provider fact sheet checked on the vendors' own pages (Elastx CaaS/DBaaS/3 Stockholm AZs/ISO 27001:2022; GleSYS VPS + S3, no managed k8s, EU-owned not Swedish-owned; Safespring S3 with Object Lock COMPLIANCE/GOVERNANCE; Berget api.berget.ai/v1 with gemma-4-31B-it vision and an SLA that excludes serverless; evroc Think Models EU-only), never as "US cloud is illegal", and it leads with the MCP server as the agent surface that needs no AI provider at all (alignment rule R5). The connector subscription is described as planned and not yet available rather than documented as if it shipped. Vercel Speed Insights is now gated behind !isSelfHosted() in app/layout.tsx (the last ungated hosted-only telemetry; read via lib/env/public-flags per the folded-flag rule). Backup/restore ship as scripts/self-host/{backup,restore}.sh (pg_dump custom format + storage volume tar + optional db-config volume for the pgsodium root key, SHA-256 manifest, AWS CLI v2 against any S3-compatible endpoint, optional COMPLIANCE-mode Object Lock) with a bash -n + refusal-path test, because self-hosted Supabase has no managed backups and BFL 7 kap needs a credible 7-year archive. Stale self-host docs fixed: the 4-of-23 cron table replaced by a pointer to the generated crontab and the pgvector line corrected (nothing stores embeddings).
@@ -1416,3 +1418,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-31] POST /sessions binds the code exchange to its state's own pending row (Superagent P1 on PR #1751): verified signature + key/service match + existing pending row are preconditions for calling EB, and a concurrent consumption of the same state after exchange closes the upstream session and answers 409 instead of handing out a session the ledger never recorded. Full code-to-state binding at the callback (recording a code hash on the pending row) would need a column and is deferred; the pre-exchange binding plus one-shot pending->active activation removes the cross-key and stateless-exchange paths, and same-key crossover between an instance's own concurrent flows only relabels its own sessions.
[2026-08-31] Ten rarely-called READ tools demoted to catalogVisibility 'search', reclaiming 3 763 tokens of the tools/list context budget (65 046 -> 61 283) and ratcheting the bench ceiling 65 000 -> 61 600. This is the demotion the payload bench deferred twice, explicitly, for want of prod usage data. Picked from 60 days of mcp.tool_called: default-catalog reads with <= 25 calls, excluding anything in RECOMMENDED_WORKFLOW_LOADOUTS. Usage data is necessary but NOT sufficient, and three classes were kept despite low counts: gnubok_call_tool (it IS the search-only bridge), the `connect_*` and `lookup_company` onboarding entries (a fresh agent has not learned to search yet, which is why #1936 put them in the catalog), and every arsredovisning/dispositioner/depreciation/accrual proposal tool (a 60-day window ending in August cannot see bokslut season, since most Swedish companies close 31 Dec and do the work Jan-Jun, so summer counts understate them to near zero). Widget tools are a hard exclusion, found the hard way: gnubok_receipt_matcher and gnubok_vat_review_widget were demoted in a first pass and their own suites failed, because a widget is rendered from the _meta.ui it publishes in tools/list and search-only hides it there. Added a general invariant so the next widget tool cannot repeat it.
[2026-08-31] SKV broker hardening (two skeptic refutations on PR #1757, same classes as the bank fixes): the token route's code exchange now requires a verified connector state (signature, key, svc 'skv') plus an existing pending row before spending Arcim's client secret, and a concurrently consumed state withholds the tokens with 409 (SKV has no revoke endpoint; the pair expires unused); refresh requires the presented token's hash to match an ACTIVE ledger row under the presenting key (it was an open refresh oracle for any leaked token); the metering redaction gains a 10+-digit rule because personnummer/orgnr/redovisare12 in SKV data-proxy paths slipped the EB-tuned thresholds and rested in cleartext; authorize-url adopts the countHeldConnections reservation re-count; all SKV base URLs are https-only (loopback excepted). SELF-HOSTING.md's connector section collapsed from three contradictory copies (accreted across the stack merges) to one.
[2026-08-31] lib/connect/instance/upstreams.ts reuses lib/entitlements/own-credentials.ts instead of duplicating the own-credentials checks: the seam was forward-ported into the entitlement partition during the stack's bottom-up merge (skeptic refutation on PR #1747), and two copies of "what counts as own credentials" would eventually disagree, splitting the gate from the routing. upstreams.ts re-exports the two functions for its instance-side callers.
[2026-08-31] UpgradeNote is self-host-aware (operator-skeptic refutation on PR #1758): on a self-host every UpgradeNote surface is by definition a connector capability (local capabilities are always on), so the component centrally swaps the hosted subscription copy + /settings/billing link for the connector-key note, mirroring CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV; the SKV connect button tooltip branches the same way. Fixed centrally rather than per panel so the EB/SKV/AGI/reports surfaces can never drift. SOVEREIGN.md updated to the merged reality (infra live, keys not sold, wiring pending). Standing rule reaffirmed: no connector key is issued before the instance-side wiring (PR6b) lands, or a paying customer sees granted capabilities with clients that still call the upstreams directly and fail on missing env credentials.