Public API and webhooks Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
Goal: A third party can call a published subset of the existing API with an API key, and can subscribe an HTTPS endpoint to a small catalogue of signed, thin webhook events, with the same tenancy, authorization and audit guarantees as a person at the web app.
Architecture: One chain, no second router. httpx.Authenticate gains an mck_ branch that resolves a key through an allowlisted SECURITY DEFINER lookup and builds the same Identity plus a Credential; Authorize intersects the key’s scopes with the owner’s current permissions; PublicSurface() refuses any route not marked x-public-api. Public operations are marked in api/openapi.yaml; a tool emits the committed api/public-openapi.yaml. Webhooks are a transactional outbox (a recorder called by db.InTx before commit) feeding a River queue webhook served by a compose worker; delivery is table-driven, SSRF-checked at save and at every attempt, and signed with the Standard Webhooks shape under a sealed per-endpoint secret.
Tech Stack: Go, pgx v5, Postgres (goose), gin, River, Redis (go-redis), gopkg.in/yaml.v3, oapi-codegen, React + TanStack Router/Query + i18next, vitest + msw.
Spec: docs/superpowers/specs/2026-10-03-public-api-webhooks-design.md (owner decisions D1-D13 in its section 14: all recommended options). The task outline in its section 14 is followed; the prerequisite keyring move is its own task K.
Gates (owner, counsel or operations; each blocks only what it names)
Section titled “Gates (owner, counsel or operations; each blocks only what it names)”- G1 - counsel reads the PDPA acknowledgement wording (spec section 10, D10). Blocks launch of the acknowledgement text, not its construction: W3 and A3 build the audited acknowledgement with a wording version
draft-1, and the screen says the text is provisional until counsel replaces it. Section 129’s current test is unverified and is not asserted anywhere in code. - G2 - the Grafana alert rules exist on the VPS (spec section 11). The rules are written into
docs/DEPLOY_NOTES.mdas a checklist in W4; creating them on the VPS is an operations step this branch cannot perform. It is reported as not done, not ticked. - G3 - worker egress policy (spec 7.7, last bullet). A deployment change on the VPS: documented in
docs/DEPLOY_NOTES.md, not applied here. The in-process SSRF guard (T17) is the control this branch ships. - G4 - the operator console’s per-org “suspend public API” action (spec 3.8). Added in A2 only if the console’s existing action pattern takes it without touching its design; otherwise reported as an open item (see A2).
Global Constraints
Section titled “Global Constraints”- Migration range:
00960-00979. The first migration (A2) adds{960, 979, "Public API and webhooks (2026-10-03-public-api-webhooks-design.md)"}tointernal/kernel/db/migrationranges_test.goin the same commit. Another agent owns01020-01039(account codes) and00940-00959(consolidation); neither is touched. - Contract first.
api/openapi.yaml, thenmake generate; generated files are never edited by hand. The tooling that emitsapi/public-openapi.yamlis not generated code: it istools/publicapi, and its output is committed and byte-compared by a drift test. - Every business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, policyorg_isolation, composite tenant FKs, named constraints,SET search_path = pg_catalog, public, pg_tempon every function. - SECURITY DEFINER:
api_key_lookupis added tosecurityDefinerswith a reason and is not executable by PUBLIC (the existing test enforces both). - Secrets: an API key’s secret is stored only as SHA-256, shown once, never logged or audited; a webhook secret is sealed with the shared keyring, shown once. Neither appears in a log line, audit row, metric label, delivery row, error string or problem body (T5).
- Money and vocabulary: a float never touches money; money is a string on the wire. Statuses (
active|paused|disabled,pending|succeeded|failed|dead) are strings; no integers for vocabulary. - Env vars: every new variable (
WEBHOOK_SECRET_KEY,WEBHOOK_SECRET_KEY_PREVIOUS,WEBHOOK_BLOCKED_CIDRS,WEBHOOK_ALLOW_INSECURE_DEV, the rate-limit variables) is in.env.example(the existing drift test enforces it). - Routes: every new route is registered with
httpx.Protected; no new line inapi/public_routes.txt(D9: the public document is committed, not served). - Permissions:
integrations.apikey.manage,integrations.webhook.manage,integrations.delivery.replay,integrations.settings.managecarry en/ms/zh labels (TestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocale), are Owner-only by default, and are neverapi_grantable. - Names are translation rows; web strings are i18n keys in en, ms and zh.
- Jobs: webhook delivery runs on River queue
webhook, added tojobs.DefaultQueuesand served by the composeworker(WORKER_QUEUES). A new drift test readsdeploy/vps/compose.yamland fails when a default queue is served by no compose worker. Every process-local loop is gated onjobs.SingletonDuties; River periodic jobs are registered on every instance (the leader enqueues once). - Lock order: any advisory lock takes a registered class and is recorded in
docs/LOCK_ORDER.md;audit.Writestays last in the transaction. - Do not touch the chart of accounts, account codes or consolidation.
- Tests: iterate on touched packages; run
go test -p 2 ./...and the web suite once in task X. Every drift test is written presence-first and watched failing once: the break step is recorded in the commit message of the task that adds the test. - Commits: one per task, ending with the attribution line the session specifies. Merging and pushing are the owner’s.
Rulings (where the spec is silent, wrong or unverified)
Section titled “Rulings (where the spec is silent, wrong or unverified)”-
PW-1 Spec corrections found by reading the code (2026-10-04). (a)
x-codesis not an existing extension (the string occurs only insidetax-codes); A1 introduces it as a list of code strings on a response and T8 enforces it for public operations only. (b) The catalogue table lists eight event types in six rows (“the seven” in D12 miscounts); all eight are built, each with its publisher. (c) The job code lives ininternal/kernel/jobs, notplatform/jobs. (d) The spec’sidentity.api_key_lookupis spelledapi_key_lookupwith no schema, likegroup_read_allowed. (e) Handlers are hand-written, not generated-interface implementations (nothing importsinternal/gen/api); the contract is held byTestTheOpenAPIDocumentSaysWhatTheRouterEnforces, and public operations inherit it. -
PW-2 Credential on the identity.
httpx.IdentitygainsCredential *Credential{KeyID uuid.UUID, Scopes map[string]struct{}}; nil for a session.SessionIDis the zero UUID for a key and nothing treats that as a session. -
PW-3 Resolver seam. Kernel declares
httpx.KeyResolver{ Resolve(ctx, raw string) (KeyIdentity, error) };platform/identity/apikeyimplements it;cmd/apiwires it throughhttpx.Deps.Keys. A router built without a resolver answersmck_tokens with the generic 401 (a key cannot authenticate), never a 500. -
PW-4 Audit and the credential (resolves spec U2).
audit_log’s hash input is fixed atpayload_version2 and is not touched. A write made through a key records the key in a side tableaudit_log_credential(org_id, audit_seq, credential_id), inserted byaudit.Writeitself in the same transaction when the context carries a credential (audit.WithCredential). Append-only (no UPDATE, DELETE). “Member M via key K” is the join. -
PW-5 Org switch. A small table
public_api_setting(org_id PK, enabled bool default false, operator_suspended bool default false, enabled_by, enabled_at): administrators writeenabled; only the operator path writesoperator_suspended.api_key_lookupreturns both. An org with no row is off. -
PW-6 Last-used write. Hourly-throttled
UPDATE api_key SET last_used_at, last_used_ip ... WHERE last_used_at < now() - interval '1 hour'inside the request’s tenant transaction.last_used_ipis a single value, not a history. -
PW-7 Generic 401. Missing, malformed, unknown, wrong-secret, expired, revoked, owner-inactive, org-suspended and API-disabled all answer the same body and the same status, and cost the same lookups (the secret hash is compared against a decoy hash when the key id is unknown).
-
PW-8 Rate limiter. A token bucket per credential (600/min, burst 100), a per-org ceiling (3000/min) and a per-IP failure limiter (20/min), Redis-backed with a Lua script; when Redis errors, a per-process in-memory bucket at a quarter of the limit and one log line per minute. The limiter takes an injected
clock.Clock. Defaults come from env (A4). -
PW-9 Public-write idempotency (as built, A4). The claim and the stored answer are two short transactions of the middleware’s own, not the handler’s, because handlers open their own; the concurrent-duplicate answer is therefore
409 idempotency_in_progress(a code the spec lists), a claim abandoned by a crash is taken over after two minutes, and a crash between the handler’s commit and the stored answer can run the write again (markedponytail:inhttpx/idempotency.go). Original ruling:api_idempotency_key(org_id, credential_id, key, request_hash, status_code, response, created_at), PK(org_id, credential_id, key). The claim is the first statement in the request’s transaction (INSERT ... ON CONFLICT DO NOTHING, then a read that blocks on a concurrent first request). The outer layer short-circuits true replays; a module’s own table stays inner. The interplay is proven by the first test of A4 on a module-keyed endpoint before anything else depends on it (spec U6). -
PW-10 Webhook payloads are the generated types.
x-webhooksmaps event type to a component schema; A0 settles whether oapi-codegen emits schemas referenced only from an extension. If it prunes them, they are referenced from a documentedx-anchor path; no hand-copied Go struct. -
PW-11 Semantic tags for workflow-driven events.
expense_claim.approved/paidfire when a transition enters a state carrying the semantic tag, never on a state name. A0 reads whetherworkflow.transitionedcarries the tags; if not, the publisher resolves them in the workflow package inside the transaction. -
PW-12 Per-endpoint concurrency cap (decided in W2). Default 2. There is no counter to leak: the pre-call transaction locks the endpoint row for the instant it takes to count deliveries whose
attempt_started_atis under 30 seconds old and, if there is room, stamps its own. A crashed attempt frees its slot when its stamp ages out. Over the cap the job is put back three seconds later. -
PW-13 Retries use table state. Jobs are triggers, rows are truth:
MaxAttempts: 1, the next attempt isInsertTxed withScheduledAtby the recording transaction, and the reconciler re-enqueuespendingrows pastnext_attempt_atwith no live job. -
PW-14 Public write surface (D11). v1 writes: create sales document (draft) and create expense claim (draft), each reusing the module’s existing create function through the same handler. Exactly which read operations are marked is decided in A5 by reading each payload for personal data; the T2 pairing test fixes the permission side.
-
PW-15 Retention of events (decided in W1, 2026-10-04). A succeeded delivery is kept 30 days, a failed or dead one 90, an event 30 days and for as long as any delivery still names it. The spec’s “replay horizon equals event retention (30 days)” would delete the event under a dead delivery on day 31 while the delivery row lives to day 90; instead a dead delivery stays replayable for 90 days.
-
PW-16 A key’s authority is enforced where permissions are read (decided in A5).
member.Permissionsnarrows to the key’s scopes when the request is a key’s (authn.WithScopes), so a handler’s ownmember.Cancheck, not only the route’sAuthorize, is answered for the key and never for its owner. Found by reading expense claim handlers, which checkexpenses.claim.read_allthemselves.Authorizestill checks the scope first and answersinsufficient_scopewithout a database call. -
PW-17
APP_ENV. The spec’s “honoured only when the environment is development” assumes an environment variable the code does not have.APP_ENV(defaultproduction) is introduced for it;WEBHOOK_ALLOW_INSECURE_DEV=truedoes nothing unlessAPP_ENV=development, and then allows http and loopback only. -
PW-18 Idempotency-Key and sessions. The header is required of a request that arrives on an API key; a session caller of the same operation (the web app) may send it but is not required to, because the shared handlers serve both. The contract marks it
required: true, which is accurate for the published document. The web client now sends one on the two shared creates. -
PW-19 The retry schedule. The spec’s eight waits (10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h) add up to about 45 hours, not “about three days”. The waits are implemented as written; the label is the error.
-
PW-20 Public v1 operations (A5). Published:
listSalesDocuments,getSalesDocument,createSalesDocument,getCustomer,createExpenseClaim. Not published, and why: reading an expense claim (its view carries masked payout details and the approvers’ decisions: personal data), the customers list (unpaginated, capped at 1000; a public list needs a keyset cursor in the shared handler), and anything that approves, posts or pays.
Task A0 - Spike: answer the spec’s unknowns, amend the spec
Section titled “Task A0 - Spike: answer the spec’s unknowns, amend the spec”Files: docs/superpowers/specs/2026-10-03-public-api-webhooks-design.md (amend section 16), docs/superpowers/plans/2026-10-03-public-api-webhooks.md (record outcomes under “Spike results” below)
- Re-check for an existing limiter and for Redis use beyond liveness; whether oapi-codegen emits schemas referenced only from
x-webhooks;oasdiffavailability (no new dependency is added; the hand-rolled compatibility check is the default); whetherworkflow.transitionedcarries destination tags; which candidate publishers exist; the Standard Webhooks header names (from the specification text, read, not recalled) and theDeprecationheader status. - Verify: each unknown is answered with the command or file that answered it, or marked unanswerable and carried as an owner item.
Task K - Shared keyring
Section titled “Task K - Shared keyring”Files: internal/kernel/keyring/keyring.go and test, internal/platform/payout/seal.go, internal/kernel/config/config.go (+ webhookkeys.go), .env.example, cmd/api/main.go, cmd/worker/main.go, docs/RUNBOOK.md
- Move
Key,Keyring,NewKeyring,Sealedand the seal/open/reseal mechanics to the kernel package;payoutkeeps its sentinel errors and its process-wide installed keyring, delegating to the shared type. No behavioural change to payout (its existing tests are the regression check). WEBHOOK_SECRET_KEYandWEBHOOK_SECRET_KEY_PREVIOUSread like the payout pair (required by the binaries that seal or open webhook secrets: api and worker), refusing a placeholder value.- Verify:
go test ./internal/kernel/keyring/... ./internal/platform/payout/... ./internal/kernel/config/... ./cmd/....
Task A1 - Contract tooling (T6, T7, T8)
Section titled “Task A1 - Contract tooling (T6, T7, T8)”Files: tools/publicapi/ (filter and compatibility check), api/openapi.yaml (the apiKeyAuth scheme, x-public-api on the first operation(s), x-codes), api/public-openapi.yaml, api/public-openapi.baseline.yaml, internal/app/publicapi_test.go (or tools/publicapi tests), Makefile (generate runs the filter)
- Marker
x-public-api: {since: "1"}plus tagPublic API; the filter emits only marked operations, the schemas they reference transitively,apiKeyAuthandbearerAuth. - T6 byte-equality, T7 compatibility against the committed baseline (additions pass; removing an operation, response field, enum value or code, or making an optional request field required fails), T8 well-formedness (non-2xx declares
x-codes; array GETs declarelimit,cursor,next_cursor; nonumberon money-named properties; nointegerenum; non-GET requiresIdempotency-Key). Each asserts a non-zero count first. - Verify: tests green, each watched failing once.
Task A2 - API keys (T1-T5, T22)
Section titled “Task A2 - API keys (T1-T5, T22)”Files: db/migrations/00960_api_key.sql, 00961_api_key_lookup.sql, 00962_public_api_setting.sql (and 00963 if the audit side table needs its own), internal/kernel/db/migrationranges_test.go, internal/kernel/db/securitydefiner_test.go, internal/kernel/httpx/{auth.go,publicsurface.go}, internal/kernel/audit (credential on the context, side table write), internal/platform/identity/apikey/, internal/platform/identity/member (api_grantable flag on the registry), cmd/api/main.go, internal/app tests
api_key,public_api_setting,audit_log_credential;api_key_lookup(key_id); resolver with SHA-256 and constant-time compare;Authenticatebranch;Credential;Authorizescope step;PublicSurface(); theapi_grantableflag.- The operator console “suspend public API” action: attempted via the console’s existing action pattern (G4).
- Verify: T1-T5 and T22 (the allowlist and range tests fail first without their entries).
Task A3 - Key administration
Section titled “Task A3 - Key administration”Files: internal/modules/integrations/ (new module), api/openapi.yaml, constants.yaml, migration 00964 (Owner grants), web/src/features/integrations/, i18n en/ms/zh, nav entry
- Endpoints: create (show-once,
Cache-Control: no-store), list, get, rotate with overlap, revoke, change scopes, the org switch; audited acknowledgement with wording version; permission keys and labels; the expiry-warning job (14 and 2 days) through the notifications module. - Verify: handler tests (403 without each key; plaintext appears once; T9 route test), web vitest, typecheck.
Task A4 - Idempotency and rate limits (T10, T11, T21 part)
Section titled “Task A4 - Idempotency and rate limits (T10, T11, T21 part)”Files: db/migrations/00966_api_idempotency_key.sql, internal/platform/apikey/ratelimit, internal/kernel/httpx (middleware), .env.example, prune job, internal/kernel/db/retention_test.go
- Verify: T10 (including the module-keyed interplay test first), T11 (including Redis down), retention listing.
Task A5 - First public operations (T8, T9)
Section titled “Task A5 - First public operations (T8, T9)”Files: api/openapi.yaml (x-public-api, x-codes, keyset pagination where the reads lack it), api/public-openapi.yaml, the touched handlers, tests
- Mark the reviewed reads and the two creates (PW-14); regenerate the public document;
Idempotency-Keyrequired on the creates; payloads reviewed for personal data and recorded in the PR text of the commit. - Verify: T2, T8, T9; a key-authenticated request against a real handler.
Task W1 - Outbox (T12-T15)
Section titled “Task W1 - Outbox (T12-T15)”Files: internal/kernel/eventbus (TxRecorder), internal/kernel/db/db.go, db/migrations/00968-00972 (webhook_endpoint, webhook_endpoint_secret, webhook_event, webhook_delivery), internal/platform/webhooks/ (catalogue, recorder, fan-out), the eight publishers, api/openapi.yaml (x-webhooks and payload schemas)
- Verify: T12-T15, with the in-transaction recorder broken once (recording after commit) to watch T12 fail.
Task W2 - Delivery (T16-T20)
Section titled “Task W2 - Delivery (T16-T20)”Files: internal/kernel/jobs (QueueWebhook, DefaultQueues), internal/platform/webhooks/{deliver,ssrf,sign,backoff,replay,reconcile}.go, cmd/worker (register worker, reconciler periodic, queue test), deploy/vps/compose.yaml, migrations 00973-00975, .env.example
- Verify: T16-T20 and the compose-serves-every-queue test (watched failing once).
Task W3 - Webhook administration
Section titled “Task W3 - Webhook administration”Files: internal/modules/integrations/, api/openapi.yaml, migration 00976 if needed, web/src/features/integrations/, i18n
- Endpoints and screens: create (show-once secret), pause, rotate, test ping, delivery log with filters, replay (per delivery and per endpoint, cap 1000), acknowledgement (wording
draft-1, G1). - Verify: handler tests, T13 across admins, vitest.
Task W4 - Metrics, runbook, retention, hygiene (T21, T23)
Section titled “Task W4 - Metrics, runbook, retention, hygiene (T21, T23)”Files: internal/platform/webhooks/metrics.go, docs/DEPLOY_NOTES.md, docs/RUNBOOK.md, internal/kernel/db/retention_test.go, prune job, docs/LOCK_ORDER.md
- Verify: T21, T23, metric registration test; G2 and G3 recorded as not done.
Task X - Integration and close-out
Section titled “Task X - Integration and close-out”Files: an end-to-end test (a local receiver, a key-authenticated create, an event delivered and verified), docs/ROADMAP.md
- Run the section-12 review: every “break it” step actually performed and recorded in a table at the end of this plan.
- Run
go test -p 2 ./...and the web suite once. Tick only what was verified.
Not in this plan
Section titled “Not in this plan”OAuth client-credentials, per-aggregate ordering, fat payloads, HR/payroll data, approvals, posting, inbound webhooks, serving the OpenAPI document (D9), sandbox orgs, SDK generation beyond the existing generator.
Spike results
Section titled “Spike results”Read 2026-10-04. Each answer names how it was found.
- No rate limiter exists (
grep -riE 'ratelimit|token bucket|x/time'overinternalandcmd, excluding myinvois, found nothing). Redis is used only bylivenessand constructed incmd/api/main.go(300 ms timeouts, retries off). The worker constructs no Redis client; limits are an API concern. - oapi-codegen prunes schemas not reachable from an operation. An experiment (a schema referenced only from a top-level
x-webhooks) produced no Go type. Withoutput-options: skip-prune: truethe type appears and the rest ofapi.gen.gogains only three unreferenced types. Ruling PW-10 becomes: setskip-prune: trueinoapi-codegen.yamlin W1; payload types are the generated ones. oasdiffis not a dependency (go.mod carries onlyoasdiff/yamlindirectly). No dependency is added; A1 writes the small comparison overgopkg.in/yaml.v3, which is already direct.workflow.TransitionedcarriesFromSemantic,ToSemanticandToFinalApproval(internal/platform/workflow/actors.go), so workflow events need no extra resolution. Ruling PW-11 made concrete:expense_claim.approvedis a transition ofworkflow.KindExpenseClaimwhoseToSemanticisaccrual;expense_claim.paidis one whoseToSemanticispayout.ToFinalApprovalis not used: it marks the last approval step being entered, which is not yet an approval.- Audit chain input is fixed at
payload_version2 (internal/kernel/audit/audit.go,canonicalPayloadSQL); adding a hashed field means a version-3 forward migration. Not taken: ruling PW-4’s side table keeps the chain untouched. - Publishers that exist:
receivables.document_issued(platform/receivables/transition.go, on entering an accrual state),workflow.transitioned,expenses.comment_added,documents.uploaded,hr.leave_rerouted. Missing and added in W1 beside their events: sales document void (receivables transitionaccrualtocancelled, same function), payment batch paid, e-Invoice validated and rejected, bank statement imported. - Standard Webhooks, read from
standard-webhooks/standard-webhooksspec/standard-webhooks.md: headerswebhook-id,webhook-timestamp(integer Unix seconds),webhook-signature(space-delimitedv1,<base64>entries); signed contentmsg_id.timestamp.payload; secret iswhsec_plus base64, the HMAC key being the decoded bytes; the specification names no tolerance (the 5 minutes in the spec design is ours). Matches the design; one detail added: the key is the base64-decoded secret. Deprecationheader is RFC 9745 (Proposed Standard), value an Item Structured Header date such asDeprecation: @1688169599. The design’s “(U: whether finalised)” is answered; the header value is a structured date, not free text.- Not answerable here, carried as owner items: the PDPA section 129 reading (counsel), the Grafana rule creation on the VPS, the worker egress policy, and per-endpoint concurrency in River (decided in W2 by PW-12).
Break-it record
Section titled “Break-it record”Each guard below was broken once, the failing message read, and the break reverted (2026-10-04). Watched means the test went red; not watched is stated, not hidden.
| Test | Break | Result |
|---|---|---|
| T1 public surface | PublicSurface made a no-op |
watched: key reached a non-public route (a 500 from its handler, not 403) |
| T2 grantable and public agree | a second permission made grantable | watched: “api-grantable but no public operation uses it” |
| T3 key cannot exceed owner | Authorize allowing a key without the owner’s permission |
watched: “the owner lost it, so the key lost it” |
| T4 dead keys are one 401 | revoked != nil predicate skipped |
watched: revoked key answered 200 |
| T5 secret never read back | the hash written into an audit row | watched (message trimmed after: it printed the whole blob) |
| T6 public document equals the filter | a hand edit of the committed file | watched |
| T7 compatibility | a response field deleted from the public file | watched: “property due_on was removed” |
| T8 well-formedness | fixture cases per rule; the real document is checked live in-test | watched on the fixture; not broken on the committed document itself |
| T11 rate limit | RateLimit made a no-op; Redis-down fallback made fail-open |
watched both |
| T14 catalogue equals contract | an event added to the registry with no schema | watched |
| T15 no personal data | employee_name added to a payload schema |
watched |
| T16 signature | timestamp dropped from the signed string | watched (both tests) |
| T17 SSRF | the dialer’s Control hook removed; zoned IPv6 address |
watched (a real bug: fe80::1%eth0 was allowed until the zone was dropped) |
| T18 dev hatch inert | the APP_ENV check ignored |
watched |
| T22 migration hygiene | the api_key_touch allowlist entry removed |
watched |
| T23 env | WEBHOOK_SECRET_KEY removed from .env.example |
watched |
| compose serves every queue | webhook:2 removed from compose |
watched |
| expiry warnings once each | the “warned” marker not written | watched |
| T8 on the committed document | one x-codes renamed in api/public-openapi.yaml itself |
watched (task X): “response 400 declares no x-codes” for both creates |
| T10 idempotency | the claim’s credential forced to uuid.Nil (every key shares one scope) |
watched (task X): a second credential’s first use answered 422, not 201 |
| T12 outbox atomicity | buf.Record in db.InTx replaced by a no-op |
watched: “PRESENCE: one event”, expected 1, got 0. This removes recording rather than moving it after commit (a post-commit call has no transaction to write into); the test depends on the in-transaction call either way |
| T13 tenant isolation | ENABLE and FORCE ROW LEVEL SECURITY removed from webhook_delivery |
watched: “B cannot list A’s deliveries” |
| T19 replay | attempts = 0 dropped from the replay reset |
watched: attempts 9, expected 0 |
| T20 auto-disable | the success reset of consecutive_failures dropped |
watched: “the success reset the counter” (49, expected 0) |
| T21 retention | RetainSucceeded set to 0; webhook_delivery removed from the list in retention_test.go |
watched both: prune removed 4 rows, expected 3; “Should be empty, but was [webhook_delivery]” |
| metrics and checklist (W4) | erp_webhook_oldest_pending_seconds renamed in metrics.go |
watched: “DEPLOY_NOTES names …, which the code does not register” (metrics_test.go, added in W4). The no-tenant-label half of that test was not broken |
Execution record: W4 and X (2026-10-04)
Section titled “Execution record: W4 and X (2026-10-04)”- Merge with main (structured account codes): two conflicts, both keep-both:
docs/LOCK_ORDER.md(the webhook and API key rows beside the chart-tree row) andmigrationranges_test.go(960-979beside1020-1039). No migration number collides (00960-00969against01020-01021). - Verified before new work, each package alone with
-p 1: kernel keyring, ratelimit, config, httpx, audit, eventbus, jobs, db; identity/apikey, identity/member; platform/webhooks; modules/integrations; internal/app; cmd/worker, migrate, orgadmin, api; tools/publicapi; and the packages whose publishers this branch touched (platform receivables, payout, payables, einvoice, banking; modules expenses, receivables). All passed;go build ./...andgo vet ./...clean. - W4 was largely built inside W2 and W3 (metrics, reconciler and prune, runbook 8.1-8.5, the DEPLOY_NOTES checklist, the retention list, the lock rows). W4 added
metrics_test.go, and the T21 and T23 break steps were performed. G2 (Grafana rules) and G3 (egress policy) remain a checklist, not applied. - X:
modules/integrations/endtoend_test.go(written in W3) drives a real bank statement import through the outbox to a signed delivery verified by a local receiver and into the delivery log. Task X performed every break-it step that had been left unwatched (table above). - Full
go test -p 1 ./...found two failures the per-package runs had missed (both fixed, the three packages re-run green):TestDependenciesPointDownwardOnly(theapikeyexpiry test importedmodules/receivablesfor its permissions; it now registers its own api-grantable test key) andpayablesTestApproveBatch403NamesWhichRefusal(it asserted a missing-permission 403 has nocode; A2 had given itforbiddenforx-codes, so the test now assertsforbidden, which is still notbatch_self_approval). The second is a visible additive change to every 403 body. - Web:
pnpm test153 files, 833 tests passed;pnpm typecheckclean. - Not done in X: no single test goes from a key-authenticated create to a verified delivery. The two published creates make drafts, which emit no catalogued event, and driving a claim to approval or a sales document to issue needs fixtures neither harness has. The halves are tested separately (
internal/app/publicapi_test.go,endtoend_test.go); the only event the end-to-end test drives isbank_statement.imported.
Execution record: G4, the operator console’s “suspend public API” action (2026-10-04)
Section titled “Execution record: G4, the operator console’s “suspend public API” action (2026-10-04)”- What existed:
public_api_setting.operator_suspendedandapi_key_lookuphonouring it (A2); nothing set it. Spec 3.8 is silent on webhooks, so the switch applies to key authentication only: webhook deliveries already queued, and endpoints, are not touched (an organization that must stop sending can have its endpoints paused by an administrator, or be suspended as a whole). - Built: migration
00962(two action kindssuspend_public_api/restore_public_apionops.operator_action;ops.public_api_status(), flags only, allowlisted, owned byerp_ops_owner, executable byerp_operatoralone). The worker (opsjobs) is the only writer ofoperator_suspended;apikey.SetEnablednever touches it, so an administrator switching the API off and on cannot clear it. A reason (1-500 characters) is required by the console form and byRequestAction, is kept in the action’s params and in the operator audit’saction_requesteddetail, and is not copied to the organization’s own trail or activity feed, which say only that the provider suspended or restored the public API (labels in en/ms/zh). The organization page shows the state (off, on, suspended) and the form. - Tests (
internal/operator/publicapi_test.go): a key works, is refused with exactly the error an unknown key gets, stays refused after an administrator turns the API off and on, works again after the operator’s restore; the reason is in the operator audit and not in the organization’s; the operator audit chain still verifies; blank, whitespace and over-long reasons are refused. The HTTP-level “same generic 401 body as a wrong key” for the operator flag was already inTestEveryDeadKeyIsTheSame401. - Watched failing once:
SetEnabledalso clearing the flag (the administrator test went red); the reason check removed (both reason tests red); the allowlist entry misspelled (TestEverySecurityDefinerIsAllowlistedAndNotPublicred). - Not done: no browser pass on the console page; the organization’s own settings screen does not tell an administrator the provider has suspended the API (the settings response does not carry the flag).