# Feishu approval submission-intent inventory

Last audited: 2026-08-03 · tracking issues #2392, #2504 and #2507.

## Invariant

Every logical Feishu approval create must persist a provider UUID and its
immutable create/finalization payload before calling
`POST /approval/v4/instances`. A retry reuses that UUID. Feishu error 60012 is
not an instance code: recovery must page the bounded approval/time listing and
verify the UUID on each instance detail. Provider correlation, the domain row,
the parallel `ApprovalRequest`, audit evidence and the intent's `completed`
transition must all become durable before the logical-submission lock is
released.

Each domain chooses one stable retry identity mode per scope. Domains that
partition unfinished requests without a pre-existing durable request row use a
payload-level `recovery_key`; SSO uses `(provider, system_name)` and identity
consumers use the requested slug. Domains that persist their own request row
first use its UUID as `intent_key`, so the exact generation remains replayable
after completion. The two modes share one provider UUID boundary but are not
mixed within a scope.

Credential material is never part of an intent payload. The persisted provider
section may contain only approval code, applicant user/open ID, rendered form
and approver/CC routing IDs. App secret, tenant token, Authorization headers and
raw exception values are forbidden.

Recovery payload retention is bounded by the intent lifecycle rather than a
wall-clock purge: while a create remains recoverable, it keeps the immutable
provider/finalization material required to reuse the same UUID. In the same
transaction that moves an intent to `completed` or `compensated`, that material
is replaced by the allowlisted `terminal-v1` receipt (`schema`, `resource_type`,
`resource_id`). Definite shared-primitive failures retain only an intent
receipt. Fingerprint, provider UUID, external instance ID, linked approval IDs,
attempt count and timestamps remain in typed columns for audit and idempotent
replay; no form answer, identity value, reason, IP, User-Agent or provider body
survives in terminal JSON. Migration 449 applies the same compaction to history.

## Production call inventory

| Domain / call | Pre-#2392 boundary | Rollout status |
| --- | --- | --- |
| Asset/seat/add-on `ApprovalService` | Migration 442 durable UUID + cron recovery | Already protected; excluded from #2392 |
| Announcement request `submit` | Redis lock, Feishu POST before any durable row | **Batch 1 protected**: shared non-asset intent + on-demand/cron recovery |
| SSO grant `_submit_locked` | Redis lock, Feishu POST before grant row | **Batch 2 protected**: provider/system recovery key + leaver compensation + cron recovery |
| API key `_submit_api_key_request_inner` | Redis lock, Feishu POST before assignment/request rows | **Final batch protected**: assignment/request-first explicit intent key + cron recovery |
| AI-tool application `submit` | Redis lock, Feishu POST before application row | **Batch 2 protected**: shared intent + on-demand/cron recovery |
| Identity-consumer `_open_approval` | Request flushed but not committed before Feishu POST | **Batch 3 protected**: slug recovery key + lifecycle compensation + cron recovery |
| VPN application `submit` | Redis lock, Feishu POST before application row | **Batch 2 protected**: shared intent + on-demand/cron recovery |
| Data-access `submit_group_grant_request` | Optional idempotency row flushed but not committed before Feishu POST | **Final batch protected**: request/items-first explicit intent key + cron recovery |
| Domain registration `submit` | Redis lock, Feishu POST before registration row | **Batch 3 protected**: shared intent + lifecycle compensation + cron recovery |
| CMDB scope request `_submit_inner` | Redis lock, Feishu POST before scope-request row | **Final batch protected**: shared intent + on-demand/cron recovery |
| CMDB DNS request `submit` + `retry_after_template_fix` | Feishu POST before request/retry correlation update | **Final batch protected**: shared intent + submit/retry recovery |
| Generic `feishu_chat_approval.submit_approval` driver | Fresh UUID and no database transaction | **Final contract protected**: caller must provide its durable UUID |

The AST regression now pins the remaining UUID-less production call set to the
empty set. New production callers cannot silently reopen the inventory.

## Schema and migration decision

Batch 1 through batch 3 needed **no intent migration**. Migration 442's
`approval_submission_intents` already has the required generic columns:
`scope`, applicant/key uniqueness, payload fingerprint, provider UUID, state,
JSON payload, external ID, attempt/error timestamps and reconciliation index.
Non-asset payloads use the existing nullable asset-specific foreign keys as
`NULL`; stable domain/approval row IDs stay in the internal finalization payload
until materialisation.

SSO and identity consumers permit multiple simultaneously valid requests per
applicant. They use the shared payload-level `recovery_key`; no schema change or
scope proliferation is needed, and completed generations remain reusable.

Data Access and API Key already own durable request UUIDs and use those as the
shared intent's explicit `intent_key`. Exact-key lookup includes completed and
failed generations: identical replay returns the original generation, while a
different fingerprint or a definite prior rejection fails closed. CMDB scope
and DNS keep implicit-key mode because their domain locks and fingerprinted
scopes define the logical in-flight generation.

Batch 2 also found that the SSO HTTP contract already accepted 1 MB of SAML
metadata while `sso_grants.saml_metadata_xml` was MySQL `TEXT` (64 KiB).
Migration 448 promotes only that column to `MEDIUMTEXT`; the submission-intent
schema itself did not change for that rollout. Large XML/X509 values live once in the intent's
finalization payload before provider I/O, move to the domain row during
finalization, and are then removed from the completed intent; terminal JSON
retains only the `sso_grant` resource pointer.

Migration 449 adds nullable indexed `next_attempt_at`. Every domain query first
filters out future attempts, then orders eligible work by
`coalesce(next_attempt_at, created_at), created_at, id`. A failed finalizer or
busy logical lock persists bounded exponential backoff (1–60 minutes), moving
that row behind due work. Unknown provider results schedule from the already
incremented provider attempt and are not incremented again by the outer
reconciler. This preserves deterministic same-UUID recovery while preventing a
poisoned fixed-size prefix from starving later or continuously arriving work.
SSO, domain-registration and identity-consumer withdrawal failures stage the
same schedule in the transaction that records their redacted error, failure
audit and desynchronization notification; they remain `compensation_pending`
and count as `still_unknown`, while retaining the correlated provider instance
until a later due attempt confirms withdrawal and compacts the terminal intent.

## Recovery states

- `prepared` / `submitting`: durable UUID exists; resubmit the same UUID.
- `unknown`: transport result was indeterminate; resubmit the same UUID, then
  resolve 60012 by bounded list/detail UUID matching.
- `provider_created`: provider code is durable; perform local finalization only.
- `compensation_pending`: provider create succeeded but a locking current read
  found the requester or required owner had become ineligible; a local cancelled
  domain row and `ApprovalRequest` preserve callback correlation while Feishu
  withdrawal is retried.
- `compensated`: the leaver-side Feishu instance is confirmed withdrawn/closed.
- `completed`: domain row, `ApprovalRequest`, audit and intent are committed.
- `failed`: provider rejected before acceptance was plausible; a later logical
  request may create a new intent generation.

The existing `feishu.approval_drift_reconciler` owns automatic recovery. It
invokes each asset/non-asset domain reconciler exactly once and aggregates each
summary exactly once. Reconcilers are scope-filtered so one finalizer can never
interpret another domain's payload.
