"""Vocabulary for the approval-flow registry (审批流程台账). The registry answers two questions and deliberately no others: **how many approval flows does the company run**, and **who is responsible for each one**. Approval nodes, form structure, instance contents and per-flow statistics are explicitly out of scope — the flows carry HR, finance and legal payloads, and a registry that mirrored them would be a second copy of data Feishu already owns and protects. """ from __future__ import annotations # ── Lifecycle ─────────────────────────────────────────────────────── STATUS_ACTIVE = "active" """在用 —— Feishu reports the definition as ACTIVE.""" STATUS_INACTIVE = "inactive" """已停用 —— Feishu reports INACTIVE. Still listed (greyed) because a stopped flow with no owner is exactly the one nobody notices, and the half-year confirmation still asks whether it should come back or go.""" STATUS_RETIRED = "retired" """已归档 —— deleted upstream, or retired by IT here. Kept as a row so last year's ownership history still resolves to a name.""" STATUSES: tuple[str, ...] = (STATUS_ACTIVE, STATUS_INACTIVE, STATUS_RETIRED) #: Statuses that still demand a named owner. A retired flow without an #: owner is not a governance gap; counting it would drag the coverage #: number down every time something is switched off correctly. LIVE_STATUSES: frozenset[str] = frozenset({STATUS_ACTIVE, STATUS_INACTIVE}) #: Upstream (Feishu) definition status → our status. FEISHU_STATUS_MAP: dict[str, str] = { "ACTIVE": STATUS_ACTIVE, "INACTIVE": STATUS_INACTIVE, "DELETED": STATUS_RETIRED, } # ── Where the row came from ───────────────────────────────────────── SOURCE_DISCOVERED = "discovered" """Found by the discovery sweep over Feishu approval instances.""" SOURCE_MANUAL = "manual" """Typed in by IT — the escape hatch for a flow that has never been used, which the instance sweep can never see.""" SOURCE_IMPORTED = "imported" """Bulk-imported from a spreadsheet.""" SOURCES: tuple[str, ...] = (SOURCE_DISCOVERED, SOURCE_MANUAL, SOURCE_IMPORTED) # ── Where the OWNER came from ─────────────────────────────────────── # # Feishu DOES carry an owner-shaped field on an approval definition: # ``approval_admin_ids``, the 流程管理员 the console shows. This comment # said the opposite for the register's whole first release — 「verified # live, comes back null」 — and the verification was real but the # conclusion was wrong: the field is absent unless the request sends # ``?with_admin_id=true``, which the definition sweep did not. The cost # was 32 of 49 flows published as 「还没人认领」 while Feishu knew their # managers by name. Re-verified 2026-08-27 with the param: 42 of 49 # definitions return at least one admin. # # It is still not a *tidy* answer, which is why provenance stays # recorded per row. A definition can name seven admins, and some of # them have left. The whole list is published as what it is — # 飞书流程管理员 — and ONE still-employed member of it is adopted as the # flow's owner, as a candidate 待确认 (see # :data:`OWNER_SOURCE_FEISHU_ADMIN` for why the pick cannot be Feishu's # own index 0). "A name is in the box" and "a human confirmed this # name" remain different facts, and only one of them is coverage. OWNER_SOURCE_UNASSIGNED = "unassigned" """待认领 —— nobody named. An honest hole, surfaced by the coverage read, never papered over with a fallback.""" OWNER_SOURCE_INFERRED = "inferred" """推断 —— derived from who actually decides this flow's approval tasks. A candidate with evidence attached, shown as 待确认 everywhere it appears; never presented as an established fact.""" OWNER_SOURCE_FEISHU_ADMIN = "feishu_admin" """飞书流程管理员 —— a 流程管理员 named on the Feishu definition, adopted as the flow's owner. Stronger evidence than :data:`OWNER_SOURCE_INFERRED` (a declaration beats a statistic, which is why it may overwrite one) and still weaker than any human decision made in Helix — it shows as 待确认 until the person says yes. **A definition with several admins also gets one** (user decision, 2026-08-27): leaving 25 of 49 flows with an empty owner because Feishu named a team rather than a person left the register's whole accountability half unusable, and one named person who can say 「不是 我」 beats nobody to ask. Which one, though, cannot come from Feishu's array order. ``approval_admin_ids`` is **rotated on every call** — measured against this tenant: five reads of 名片申请 returned four different orders and four different "first" people. Taking index 0 literally would re-point the owner most nights, write a transfer event each time, and send the half-year card to whoever won that morning's roll. So the pick is :data:`OWNER_PICK_KEY`-ordered and, once made, sticky: the incumbent keeps the flow for as long as they are still on the list and still here. See ``discovery._pick_admin_owner``.""" OWNER_PICK_KEY = "user_id" """What the multi-admin owner pick is ordered by. Arbitrary on purpose — any tie-break among a team's administrators is arbitrary, and the property that matters is that it is the SAME arbitrary answer every night. Feishu's own ordering is the one thing it cannot be.""" OWNER_SOURCE_MANUAL = "manual" """IT 指派 —— a human named them in Helix.""" OWNER_SOURCE_IMPORTED = "imported" """Came in with a bulk import.""" OWNER_SOURCE_CONFIRMED = "confirmed" """本人确认 —— the owner themselves said yes on the half-year card. The only source that counts as confirmed coverage.""" OWNER_SOURCES: tuple[str, ...] = ( OWNER_SOURCE_UNASSIGNED, OWNER_SOURCE_INFERRED, OWNER_SOURCE_FEISHU_ADMIN, OWNER_SOURCE_MANUAL, OWNER_SOURCE_IMPORTED, OWNER_SOURCE_CONFIRMED, ) #: Owner values a human has actually stood behind. Inference may #: overwrite anything OUTSIDE this set and nothing inside it — a sweep #: that silently re-points a human decision is worse than no sweep. HUMAN_OWNER_SOURCES: frozenset[str] = frozenset({ OWNER_SOURCE_MANUAL, OWNER_SOURCE_IMPORTED, OWNER_SOURCE_CONFIRMED, }) #: Machine-written owner values, in ascending order of evidence. Task #: inference must not overwrite a Feishu-declared admin: the statistic #: is the weaker signal and letting the two fight would flip a row's #: owner back and forth on alternating sweeps. MACHINE_OWNER_SOURCES: tuple[str, ...] = ( OWNER_SOURCE_UNASSIGNED, OWNER_SOURCE_INFERRED, OWNER_SOURCE_FEISHU_ADMIN, ) #: Sources the task-inference sweep is allowed to write over. Excludes #: ``feishu_admin`` by the rule above, and every human source. INFERENCE_OVERWRITABLE_SOURCES: frozenset[str] = frozenset({ OWNER_SOURCE_UNASSIGNED, OWNER_SOURCE_INFERRED, }) #: Hard cap on how many 流程管理员 a row keeps. Feishu allows a long #: list; the widest in this tenant is 7. The cap bounds the JSON column #: and the card, and a flow that needed more than this to say 「找谁」 #: has a governance problem no register can render its way out of. MAX_FEISHU_ADMINS = 12 # ── Events ────────────────────────────────────────────────────────── EVENT_DISCOVERED = "discovered" EVENT_CREATED = "created" EVENT_RENAMED = "renamed" EVENT_STATUS_CHANGED = "status_changed" EVENT_CATEGORY_CHANGED = "category_changed" EVENT_OWNER_ASSIGNED = "owner_assigned" EVENT_OWNER_TRANSFERRED = "owner_transferred" EVENT_OWNER_INFERRED = "owner_inferred" EVENT_OWNER_FROM_FEISHU_ADMIN = "owner_from_feishu_admin" EVENT_FEISHU_ADMINS_CHANGED = "feishu_admins_changed" EVENT_OWNER_CONFIRMED = "owner_confirmed" EVENT_OWNER_CLEARED = "owner_cleared" EVENT_OWNER_DISPUTED = "owner_disputed" EVENT_PUBLISHED_CHANGED = "published_changed" EVENT_RETIRED = "retired" EVENT_UPDATED = "updated" EVENT_TYPES: tuple[str, ...] = ( EVENT_DISCOVERED, EVENT_CREATED, EVENT_RENAMED, EVENT_STATUS_CHANGED, EVENT_CATEGORY_CHANGED, EVENT_OWNER_ASSIGNED, EVENT_OWNER_TRANSFERRED, EVENT_OWNER_INFERRED, EVENT_OWNER_FROM_FEISHU_ADMIN, EVENT_FEISHU_ADMINS_CHANGED, EVENT_OWNER_CONFIRMED, EVENT_OWNER_CLEARED, EVENT_OWNER_DISPUTED, EVENT_PUBLISHED_CHANGED, EVENT_RETIRED, EVENT_UPDATED, ) # ── Field bounds ──────────────────────────────────────────────────── MAX_NAME_CHARS = 255 MAX_CATEGORY_CHARS = 128 MAX_CODE_CHARS = 128 MAX_DESCRIPTION_CHARS = 2000 MAX_IMPORT_ROWS = 500 # ── Owner inference ───────────────────────────────────────────────── # # Two thresholds, both deliberately conservative. A flow whose approver # is "the applicant's line manager" spreads its tasks across dozens of # managers; picking the busiest of them would name someone who has no # say over the flow at all. Requiring a dominant share AND a minimum # volume means such a flow lands in 待认领 instead — which is the # correct answer, and the one the confirmation round can fix. MIN_INFERENCE_TASKS = 3 """Fewer decisions than this is an anecdote, not a pattern.""" MIN_INFERENCE_SHARE = 0.5 """The top approver must hold more than half of the flow's decisions.""" #: How far back the owner-inference sweep looks. Long enough to survive #: a quiet quarter, short enough that someone who moved on last year #: does not keep the flow. INFERENCE_WINDOW_DAYS = 365 # ── Discovery ─────────────────────────────────────────────────────── #: Feishu caps ``page_size`` at 200 on both search endpoints. DISCOVERY_PAGE_SIZE = 200 #: Per-person page ceiling. A prolific approver has thousands of rows; #: we only need the distinct codes, and the sweep runs over the whole #: company, so the tail adds pages without adding flows. Exceeding it is #: reported in the run summary rather than silently swallowed — a #: truncation nobody sees reads as "we covered everything". DISCOVERY_MAX_PAGES = 30 #: Concurrent in-flight Feishu requests. The documented ceiling is #: 1000/min and 50/s; this leaves headroom for the approval flows the #: platform runs for real (submissions, callbacks) while the sweep runs. DISCOVERY_CONCURRENCY = 6 #: Emails per ``contact/v3/users/batch_get_id`` call. EMAIL_LOOKUP_CHUNK = 50 #: Fresh-confirmation horizon. Anything confirmed longer ago than this #: is due again — the half-year cadence plus a grace month, so a #: campaign that opens a few weeks late does not flip the whole #: register to 逾期 the day before it runs. CONFIRMATION_VALID_DAYS = 210 # ── Half-yearly confirmation rounds ───────────────────────────────── ROUND_OPEN = "open" ROUND_CLOSED = "closed" ROUND_STATUSES: tuple[str, ...] = (ROUND_OPEN, ROUND_CLOSED) ITEM_PENDING = "pending" """Sent, nobody has answered.""" ITEM_CONFIRMED = "confirmed" """The owner said it is still theirs.""" ITEM_TRANSFERRED = "transferred" """The owner handed it to somebody else. Counts as answered — the round is measuring whether a human engaged, not whether the name stayed.""" ITEM_LAPSED = "lapsed" """The round closed with no answer. Deliberately NOT collapsed into ``pending``: at close time the difference between "still waiting" and "nobody ever answered" is the entire finding.""" ITEM_DROPPED = "dropped" """The flow was archived, or the owner left, mid-round. Excluded from the completion rate — chasing an answer about a retired flow, or from somebody who has gone, is not a governance gap.""" ITEM_STATES: tuple[str, ...] = ( ITEM_PENDING, ITEM_CONFIRMED, ITEM_TRANSFERRED, ITEM_LAPSED, ITEM_DROPPED, ) #: States that count as "the owner answered". ANSWERED_STATES: frozenset[str] = frozenset({ITEM_CONFIRMED, ITEM_TRANSFERRED}) #: Days after opening before an unanswered item is escalated to the #: owner's manager. Long enough to survive a holiday, short enough that #: the round still closes inside the half-year. ESCALATE_AFTER_DAYS = 14 #: Days after opening before the round is due. The card says this date. ROUND_DUE_DAYS = 30 #: The round opens only inside this many days after a boundary #: (June 1 / December 1). A deploy in the middle of a half-year must not #: retroactively open a round nobody was expecting — the same storm #: guard the半年度访问自查 uses. ROUND_WINDOW_DAYS = 7 #: (month, day, label suffix) for each half-year boundary. ROUND_BOUNDARIES: tuple[tuple[int, int, str], ...] = ((6, 1, "H1"), (12, 1, "H2")) ACTION_TOKEN_PURPOSE = "approval_flow_confirm" """Purpose claim on the one-click card token. A token minted for this cannot be redirected at another action.""" ACTION_TOKEN_TTL_DAYS = 45 """Outlives ``ROUND_DUE_DAYS`` plus the escalation nudge, so the second push's button still works. Shorter than the half-year, so a card found in a chat backlog next season is inert.""" # ── New-flow requests ─────────────────────────────────────────────── # # An employee who needs an approval that does not exist yet has, until # now, had nowhere to ask: the register publishes what exists, and the # Feishu approval console is IT's. This is the front door. # # Deliberately NOT routed through a Feishu approval template of its own. # The request's approver IS IT, and what IT does next is manual work in # the Feishu console — so a Feishu round-trip would mean creating one # more approval template in order to be allowed to ask for approval # templates, for no decision the platform does not already own. The # other 18 flows go through Feishu because their approvers are the # business; this one's approver is the person who will do the work. FLOW_REQ_PENDING = "pending" """Waiting for IT.""" FLOW_REQ_APPROVED = "approved" """IT said yes. The flow still has to be BUILT in the Feishu console — the request is not finished until its code comes back.""" FLOW_REQ_REGISTERED = "registered" """Built in Feishu, code filled in, registered here with the requester as its owner. This is the only terminal success state: an ``approved`` request nobody built is an employee still waiting.""" FLOW_REQ_REJECTED = "rejected" FLOW_REQ_CANCELLED = "cancelled" """Withdrawn by the requester.""" FLOW_REQ_STATUSES: tuple[str, ...] = ( FLOW_REQ_PENDING, FLOW_REQ_APPROVED, FLOW_REQ_REGISTERED, FLOW_REQ_REJECTED, FLOW_REQ_CANCELLED, ) #: Still waiting on somebody. ``approved`` is open on purpose — it is #: the state where a request most often goes quiet, because it looks #: finished from IT's side and is not from the employee's. FLOW_REQ_OPEN_STATUSES: frozenset[str] = frozenset({ FLOW_REQ_PENDING, FLOW_REQ_APPROVED, }) MAX_PURPOSE_CHARS = 2000 MAX_LISTED_FLOWS = 12 """How many flow names one handover card spells out. A leaver who owned thirty flows should hand over a readable card and a link, not a scroll. The overflow is stated (「另有 N 条」) rather than silently trimmed. """ # ── Why a flow changed hands, in the words the new owner needs ─────── # # Named here rather than in ``notify`` because ``set_owner`` defaults to # announcing, and a domain's setter should not have to import its # notifier just to spell its own default. HANDOVER_TRANSFER = "transfer" """The previous owner handed it over during the half-year check.""" HANDOVER_OFFBOARDING = "offboarding" """The previous owner is leaving.""" HANDOVER_ASSIGNED = "assigned" """IT recorded a new owner from the console. The default: an assignment nobody categorised is still an assignment somebody should hear about.""" # ── 飞书后台待同步(流程管理员漂移)───────────────────────────────── # # The register can re-point a flow's owner; the Feishu approval console # cannot be re-pointed from here. ``GET /approval/v4/approvals/{code}`` # reads 流程管理员 (with ``?with_admin_id=true``); there is no matching # write — the v4 definition surface is create / get / subscribe, with no # update verb at all, so 流程管理员 is editable ONLY by a human in the # console. Verified against ``app/approval/feishu_client.py``: every # definition-level call it makes is a read. # # So the moment an owner hands a flow over here, the two systems # disagree: Helix says the successor, the console still names the # predecessor — and the console is the one that decides who can edit the # flow, see its data and receive its admin notices. Nothing in the # platform can close that gap; only a person in the console can. What # the platform CAN do is refuse to let the disagreement be invisible. # # Drift is DERIVED, never stored as a flag: the nightly sweep re-reads # ``feishu_admins``, so the moment somebody fixes the console the row # stops drifting on its own. A stored boolean would need its own # clearing path and would eventually claim drift that was fixed weeks # ago. The only thing persisted is when we last told IT about it # (``feishu_admin_drift_notified_at``), which is bookkeeping about the # push and not a claim about the world. DRIFT_RENAG_DAYS = 14 """How long before a still-drifting flow is included in the digest again. Once-and-forget would turn a real backlog into a card somebody scrolled past in June; daily would train IT to mute it. Matches ``ESCALATE_AFTER_DAYS`` — the same judgement about how often it is fair to ask a busy person about the same thing twice.""" MAX_DRIFT_LISTED = 15 """How many drifting flows the digest card spells out. The overflow is stated, not silently trimmed, and the page behind the button has all of them.""" FEISHU_APPROVAL_ADMIN_URL = ( "https://www.feishu.cn/approval/admin/approvalList?from=approval_web_pc" ) """Where the fix actually happens. Deliberately not role-gated in the frontend twin of this link either: Feishu judges permission itself, and hiding it behind Helix's ``global_admin`` would hide it from exactly the 流程管理员 population this push is about."""