Skip to content

Clarification engine

Architecture

Normative spec

This page documents two paired specs:

  • .ai/specs/integration/2026-07-04-temporal-clarification.md ("Telha Asks Back"), Status: Approved (final review 2026-07-04 by J. Porter, responder-assertion condition incorporated in v0.6).
  • .ai/specs/app-layer/2026-07-04-chat-delivery-surface.md, Status: Approved (security review 2026-07-04 by J. Porter, nonce lifecycle, hook verification ordering, and trust-chain conditions incorporated in v0.4).

Implemented by core-engine/src/clarify/mod.rs (the full lifecycle: detection intake, ranking, quorum, binding, GDPR attribution erasure) and app-layer/packages/clarify-bot/ (delivery, callbacks, identity binding). Companion to Concepts › When Telha is not sure, it asks and the clarify-bot operator runbook.

Ambiguity detected at ingestion becomes a CLARIFICATION node, a graph-derived routing job, and (once answered) an append-only bitemporal correction. This page covers the code-level shape of that loop: the enums, the ranking algorithm, the answer state machine, and the wire contract to the chat delivery surface.

Overview

Clarification is governed evidence capture, not chat (spec §1). Four guarantees hold end to end, each owned by a specific module:

Guarantee Owner
Who may be asked ClarifyOps::rank_candidates (graph-derived, depth-1 traversal)
What they may be shown the clarify-bot's RBAC/ACL floor check before send
How answers bind ClarifyOps::answer (quorum, conflict, steward override)
How pending uncertainty surfaces ClarifyOps::pending_caveat

The loop spans two lanes. Core (core-engine/src/clarify/mod.rs) owns detection intake, the CLARIFICATION node, ranking, the answer endpoint's outcome matrix, and the bitemporal correction. The app layer (app-layer/packages/clarify-bot/) owns delivery: it leases the routing job, performs the read-authorization check, renders the one-tap card, and is the only credential class allowed to assert a third-party responder on the answer endpoint.

flowchart TB
    subgraph worker["Ingestion worker"]
      DETECT["Ambiguity detection\n(docling / tabular / email)"]
    end
    subgraph core["core-engine/src/clarify"]
      PLAN["plan_ambiguities"]
      RANK["rank_candidates\n(graph traversal)"]
      NODE["CLARIFICATION node\n+ CLARIFIES edge"]
      ANSWER["ClarifyOps::answer\n(quorum + conflict state machine)"]
      BIND["bind()\nbitemporal correction"]
    end
    subgraph bot["clarify-bot (app-layer)"]
      LEASE["Lease clarify:temporal job"]
      ACL["ACL floor check"]
      CARD["Teams / Slack card"]
    end

    DETECT -->|Submission.ambiguities| PLAN
    PLAN --> RANK
    RANK --> NODE
    NODE -->|enqueue clarify:temporal| LEASE
    LEASE --> ACL
    ACL --> CARD
    CARD -->|POST /v1/clarifications/:id/answer| ANSWER
    ANSWER -->|policy satisfied| BIND
    BIND -->|new NodeVersion| NODE

Design

Ambiguity detection is a worker concern; core owns the wire shape

The spec draws a hard line: "Detection heuristics per format belong to the worker specs; this spec owns the wire shape, lifecycle, routing, authorization, and binding policy" (spec §1). Workers emit an ambiguities array riding in the Submission JSON, validated by plan_ambiguities (core-engine/src/clarify/mod.rs), which is pure and runs before any graph writes.

Four detection patterns are modeled as the AmbiguityKind enum:

pub enum AmbiguityKind {
    RelativeDate,             // "effective next quarter"
    ConflictingDates,         // signature vs countersignature
    Undated,                  // no date at all
    LowConfidenceExtraction,  // the extractor is unsure of its own reading
}

Two confidences are modeled as separate axes, deliberately never conflated (spec §5): detector_confidence (how sure the worker is that ambiguity exists, gates clarification creation) and applied_candidate_confidence (how likely the applied best-guess interval is correct, drives answer caveats and severity escalation).

plan_ambiguities validates, per ambiguity: node_ref is in bounds, at least one candidate is present, both confidences are in [0, 1], any span_ref resolves against the submission's span table, and any candidate interval satisfies from < to. It then computes the deterministic clarification_id and, when the first candidate carries an interval, the applied_interval to stamp on the fact version at reduced confidence. At most one ambiguity per submission node is accepted in v1; a second ambiguity on the same node_ref is rejected as a worker bug (submission-level Invalid).

Dedup identity is stronger than the spec's original text

Spec §5 names (source_version_tx, span, kind) as the dedup key. The as-built (§14 changelog, v0.7) tightened this to (tenant, source identity, content hash, span, kind), because source_version_tx changes on a same-job crash-retry re-apply while the content hash does not. clarification_id (core-engine/src/clarify/mod.rs) hashes exactly this tuple with xxhash_rust::xxh3::xxh3_128, so re-ingesting an identical source version, or retrying after a crash, never reopens an answered clarification (open_planned treats NodeOpsError::AlreadyExists as "already raised, do not reopen," not as an error).

Who to ask: rank_candidates, not a routing table

ClarifyOps::rank_candidates (core-engine/src/clarify/mod.rs) is the real function behind the "who to ask comes from the graph" behavior described in the concepts page. It runs a depth-1, both-direction traversal (Traversal::expand with ExpandSpec { depth: 1, direction: ExpandDirection::Both, .. }) out of the target fact node, then buckets the neighboring edges by type against a fixed priority table:

const AUTHORITY_RANKS: &[&[&str]] = &[
    &["AUTHORED_BY", "SENT_BY", "LAST_MODIFIED_BY"],
    &["MENTIONS", "NAMES", "TO", "CC"],
    &["OWNED_BY"],
];

This is exactly the order clarifications.md states in product terms (source-span author, person named in the fact, source-record owner): rank 0 is authorship-shaped edges, rank 1 is mention/addressing edges, rank 2 is ownership. For every neighboring edge whose type falls in one of these buckets, rank_candidates reads the neighbor node as of now and keeps it only if it carries the PERSON label. Each person's best (lowest) rank and first-seen order are tracked in a BTreeMap<Uuid, (rank, order)>, so a person reachable through both an AUTHORED_BY edge and a CC edge is kept at their best rank, not double-counted. The final list is sorted by (rank, order) and truncated to config.max_candidates.

The steward fallback is not part of the ranking

rank_candidates never includes the steward. The steward is always the job's final attempt (ranking.len() + 1), wired through max_attempts on the enqueued job, not a ranked candidate. This matches spec §5: "the steward fallback is NOT part of the ranking, it is always the final job attempt."

Severity and quorum are derived, not asserted

severity_for(kind, hint) computes severity from the worker's advisory hint and a tenant-configured elevation table, with elevation as a ceiling-raising max, never a downgrade:

pub fn severity_for(&self, kind: AmbiguityKind, hint: Option<Severity>) -> Severity {
    let hinted = hint.unwrap_or(Severity::Normal);
    let elevated = self.config.severity.elevate.get(kind.as_str())
        .and_then(|s| Severity::parse(s))
        .unwrap_or(Severity::Low);
    hinted.max(elevated)
}

Severity derives PartialOrd, Ord with variant order Low < Normal < High < Critical, so elevate.conflicting_dates = "high" can only ever raise a conflicting_dates ambiguity to at least high, never lower it. As-built (§14, v0.8): workers emit no severity_hint in v1: "no worker rule currently justifies overriding it." Severity assignment today is entirely hinted = Normal elevated by tenant policy.

quorum_for(severity) derives the QuorumPolicy and initial QuorumState at creation time:

pub fn quorum_for(&self, severity: Severity) -> (QuorumPolicy, QuorumState) {
    let policy = QuorumPolicy {
        required_confirmations: if severity == Severity::Critical && self.config.quorum.enabled {
            self.config.quorum.critical_required_confirmations
        } else { 1 },
        required_roles: /* config.quorum.required_roles, default [Editor] */,
        allow_steward_override: self.config.quorum.allow_steward_override,
    };
    let state = if policy.required_confirmations > 1 {
        QuorumState::Pending
    } else {
        QuorumState::NotRequired
    };
    (policy, state)
}

Only Critical severity (with quorum enabled) ever asks for more than one confirmation. The policy is pinned at creation and never recomputed from a live config value later, so a mid-flight config change cannot alter the binding requirement of an already-open clarification.

Permission check before delivery

The permission check that gates whether a person ever sees the span text is implemented on the delivery side, not in core's ranking. Per the chat delivery surface as-built (§14, v0.5, point 3): the bot resolves GET /v1/clarifications/:id, which returns aclJson (the source's access-control list) and askedAliases, and performs an ACL floor check that resolves ACL principals to person ids by pinned rules (aad_oid to person:<uuid>, email to person-email:<address>). Unknown principal kinds are ignored; an unparseable aclJson fails closed to the NO_READ_PERMISSION skip reason. This is the code-level realization of clarifications.md's "a permission check happens before anything is sent."

Fail closed, not open

An unparseable ACL is treated as "no permission," never as "permission granted." A candidate who fails the floor check is skipped with a machine-readable reason code (NO_READ_PERMISSION among others, spec §6) and routing advances to the next candidate; core records the skip, the bot performs the check.

The job that IS the delivery

Core enqueues exactly one clarify:temporal job per opened clarification (open_planned, only when severity > Severity::Low), with payload ClarifyJobPayload { v, clarification_id, ranking }. Per the chat-delivery as-built (§14, v0.5, point 1), the bot never calls SubmitIngestionResult: the job's lease lifecycle is the delivery protocol. The bot sends (or digests, or refreshes a terminal-state card) exactly one card per lease, then deliberately stops heartbeating. The lease expires, the reaper re-pends the job with attempts + 1, and the per-kind backoff (base = escalation_window_hours) is what advances the ranking: attempts indexes directly into ranking, so attempt N asks ranking[N], and the final attempt (ranking.len() + 1, max_attempts on the job) is the redacted steward escalation.

The answer state machine

ClarifyOps::answer (core-engine/src/clarify/mod.rs) is the single entry point implementing the spec §6 outcome matrix. It is guarded end to end by a Mutex<()> (answer_lock) that serializes the read-modify-write on both the clarification node and the fact correction, so two concurrent answers can never race past the quorum check.

Its trust resolution runs before any read side effects, per spec §6: a 403 RESPONDER_ASSERTION_FORBIDDEN must leave no trace. The caller arrives as one of two AnswerCaller variants:

pub enum AnswerCaller {
    /// A "clarify"-audience service caller: it has verified the chat
    /// callback identity and mapped it to a PERSON, so it may assert a
    /// third-party responder and their rights.
    ClarifyService { responder: Uuid, rights: Rights },
    /// Any other authenticated caller (API key, CLI): binds, attests,
    /// or overrides only as the principal associated with the credential.
    Principal {
        person: Option<Uuid>,
        rights: Option<Rights>,
        asserted_responder: Option<Uuid>,
    },
}

A Principal caller supplying an asserted_responder that differs from their own person is rejected outright, before the clarification is even read. This is the code-level home of the "responder assertion rule" (spec §6, approval condition, incorporated 2026-07-04): only a "clarify"-audience service token, which has already verified the Teams/Slack callback, may submit a responder other than itself.

From there, answer walks a fixed decision order:

  1. Normalize the answer (normalise_answer): exactly one of answer_index or answer_custom is required. An index answer resolves against clar.candidates; a custom answer parses valid_from/valid_to (µs int or RFC3339 string) into an Interval. Anything that cannot resolve to a structured interval returns ClarifyError::Unparseable, surfaced as 422 CLARIFY_UNPARSEABLE_ANSWER.
  2. Idempotent replay check: if this responder already has a response whose resolved interval (answer_key_with) matches the new one, the prior outcome is returned verbatim (prior_outcome), with no new record appended.
  3. Post-binding path: if the clarification is already Answered, an agreeing duplicate from a new responder is 409 CLARIFY_ALREADY_ANSWERED; a disagreeing one is recorded as a Conflicting attestation, a steward_notified audit event fires, and the bound correction is left untouched (AnswerOutcome::Conflict { post_binding: true }).
  4. Attestation path: if the responder's rights_at_answer_time is below the policy's minimum required role, the response is recorded with BindingStatus::Attestation and the state advances to Attested (if it was Open). It counts toward nothing until an editor or steward confirms it.
  5. Steward override path: an explicit steward_override flag (never implicit) requires Rights::Steward and quorum_policy.allow_steward_override; it records BindingStatus::StewardOverride and binds immediately.
  6. Binding-capable path: the response is appended as BindingCandidate, then the active set (each responder's latest binding-capable response, at or above the minimum role) is recomputed. If the active set's resolved intervals disagree, the clarification enters Conflicted and a steward_notified event fires: no correction is created. If they agree but the count is below required_confirmations, the state becomes PartiallyConfirmed and the answer returns PartialConfirmation { quorum_remaining }. If the count meets or exceeds the requirement, the counting responses are re-marked Bound and bind is called.
flowchart TB
    START["POST /v1/clarifications/:id/answer"] --> TRUST{"Caller is\nClarifyService\nor Principal?"}
    TRUST -->|"Principal asserts\nother's identity"| REJECT["403 RESPONDER_ASSERTION_FORBIDDEN\n(no record written)"]
    TRUST -->|resolved| NORM{"Normalizes to\nstructured interval?"}
    NORM -->|no| UNPARSE["422 CLARIFY_UNPARSEABLE_ANSWER"]
    NORM -->|yes| REPLAY{"Same responder,\nsame answer\nalready recorded?"}
    REPLAY -->|yes| PRIOR["Return prior outcome\n(no new record)"]
    REPLAY -->|no| BOUND{"Clarification\nalready Answered?"}
    BOUND -->|"yes, agrees"| ALREADY["409 CLARIFY_ALREADY_ANSWERED"]
    BOUND -->|"yes, disagrees"| POSTCONFLICT["Conflicting attestation\nsteward notified\nbinding untouched"]
    BOUND -->|no| RIGHTS{"rights >=\nminimum required role?"}
    RIGHTS -->|no| ATTEST["Attestation recorded\nstate -> attested"]
    RIGHTS -->|yes| OVERRIDE{"steward_override\nflag set?"}
    OVERRIDE -->|yes| STEWARDBIND["StewardOverride response\nbind() immediately"]
    OVERRIDE -->|no| ACTIVE["Recompute active set\n(latest binding-capable\nper responder)"]
    ACTIVE --> AGREE{"All active answers\nresolve to same interval?"}
    AGREE -->|no| CONFLICT["state -> conflicted\nquorum_state -> conflicted\nsteward notified\nNO fact version"]
    AGREE -->|yes| QUORUM{"confirmations >=\nrequired_confirmations?"}
    QUORUM -->|no| PARTIAL["state -> partially_confirmed\n202 quorum_remaining"]
    QUORUM -->|yes| MARKBOUND["Re-mark counting\nresponses Bound"]
    MARKBOUND --> BINDCALL["bind(): bitemporal\ncorrection"]

Bitemporal correction on bind

bind (core-engine/src/clarify/mod.rs) performs the actual correction. It reads the target's current NodeVersion, then calls NodeOps::update_node with the same labels and properties but the normalized answer's Interval as the new valid_time, and Provenance.clarification set to the clarification's id. This is a plain call into the append-only write path documented in The version record model: update_node closes the prior version's tx_time.end at the new transaction time and writes the new version at a new key with tx_time open. The superseded best-guess belief is never deleted; it remains reconstructable at its original transaction time, exactly the winner-rule guarantee the tritemporal model describes.

bind also decides the fan-out shape (spec §10, "targeted fan-out"): it assembles the embed text for both the old and corrected versions (crate::vector::embed::assemble_embed_text, capped at EMBED_COMPARE_MAX_BYTES on both sides) and returns whether they differ. Temporal, evidence, and confidence indexes are always invalidated by the version write itself; a full re-embed is triggered only when embed_text_changed is true, i.e. only when the correction changed semantic text, not just the validity interval or provenance.

Two confidences drive two different things, downstream too

applied_candidate_confidence (the pre-answer, reduced-confidence guess) is what a pending clarification's caveat exposes in the evidence chain. Once bound, the corrected fact version carries full, restored confidence: the caveat only ever applied to the interim best guess, never to the confirmed correction.

Data model

CLARIFICATION node properties

Stored as a CLARIFICATION-labeled NodeVersion (world state, not a dedicated column family). Clarification is the parsed, in-memory view; clar_properties / parse_clarification (core-engine/src/clarify/mod.rs) are the property-level codec:

pub struct Clarification {
    pub id: Uuid,                              // = node logical id (deterministic dedup id)
    pub target: Uuid,                          // the fact node this clarifies
    pub kind: AmbiguityKind,
    pub severity: Severity,
    pub state: ClarifyState,
    pub candidates: Vec<CandidateIn>,          // ranked interpretations
    pub asked: Vec<Uuid>,                      // ranking, person ids
    pub responses: Vec<ResponseRecord>,        // append-only
    pub quorum_policy: QuorumPolicy,           // pinned at creation
    pub quorum_state: QuorumState,
    pub detector_confidence: f64,
    pub applied_candidate_confidence: f64,
    pub source_version_tx: Ts,
    pub span: Option<(u64, u64)>,              // canonical-text byte offsets
    pub fact_version_tx: Option<Ts>,           // set once bound
    pub events: Vec<AuditEvent>,               // embedded audit trail
}

Properties are stored individually (target, kind, severity, state, quorum_state, detector_confidence, applied_candidate_confidence, source_version_tx, optional span_start/span_end/fact_version_tx), and four fields are stored as embedded JSON strings: candidates_json, asked_json, responses_json, quorum_policy_json, events_json. There is a CLARIFIES edge from the clarification to its target node (CLARIFIES_EDGE_TYPE).

Response record

pub struct ResponseRecord {
    pub response_id: Uuid,
    pub responder: Uuid,
    pub rights_at_answer_time: Rights,          // Viewer | Editor | Steward, captured, never re-derived
    pub answer_index: Option<usize>,            // one-tap pick
    pub answer_custom: Option<CustomAnswer>,     // "Something else..." normalised
    pub channel: String,                        // teams | slack | cli | api
    pub message_ref: Option<String>,
    pub response_tx: Ts,
    pub binding_status: BindingStatus,           // re-marked Bound when it counts
}

pub enum BindingStatus {
    BindingCandidate,   // binding-capable, not yet counted
    Bound,              // counted in the confirmation set that bound
    Attestation,        // non-binding (viewer, or unparseable-answer record)
    Conflicting,        // disagrees with the bound correction or a concurrent candidate
    StewardOverride,    // a steward's conflict resolution / explicit override
}

Responses are plural and append-only from the first commit: the spec (§9, Alternatives Considered) explicitly rejected a single-answer (answered_by/answer_index at top level) shape as a data model that would need a breaking retrofit to support quorum and conflicts later.

Severity, quorum policy, quorum state

pub enum Severity { Low, Normal, High, Critical }   // Ord: Low < Normal < High < Critical

pub struct QuorumPolicy {
    pub required_confirmations: u32,   // > 1 only for Critical with quorum enabled
    pub required_roles: Vec<Rights>,   // minimum role(s) that count toward quorum
    pub allow_steward_override: bool,
}

pub enum QuorumState { NotRequired, Pending, Satisfied, Conflicted }
Severity Behavior (fixed in v1, spec §5)
Low Stored pending; never asked
Normal Asked, batched into a digest
High Asked directly; steward notified
Critical Asked directly; quorum required

Lifecycle state

pub enum ClarifyState {
    Open,               // no usable response yet
    Attested,           // only non-binding responses recorded
    PartiallyConfirmed, // binding-capable response(s), quorum not yet satisfied
    Answered,           // quorum satisfied (or not required); correction bound
    Conflicted,         // binding-capable responses disagree; steward review required
    Expired,            // escalation window exhausted for the current candidate
    DeadLettered,       // ranking exhausted; steward digest
}

needs_steward_attention (core-engine/src/clarify/mod.rs) is the function behind telha clarify queue: Conflicted, Expired, and DeadLettered are always in scope; Open, Attested, and PartiallyConfirmed are in scope only at severity >= High.

Job payload

pub struct ClarifyJobPayload {
    pub v: u32,
    pub clarification_id: Uuid,
    pub ranking: Vec<Uuid>,   // graph-derived candidate person ids
}

Kind: "clarify:temporal" (JOB_KIND), prefix-fenced to the "clarify" token audience via JOB_KIND_PREFIX = "clarify:". Priority at enqueue: Critical to 0, High to 1, everything else to 2.

Card action payload (chat-delivery-surface spec §6)

The discriminated action every Teams/Slack card button or modal submission carries, per the app-layer spec:

{
  "kind": "clarify_answer",
  "clarification_id": "3fbb...",
  "answer_index": 0,
  "nonce": "a1b2c3..."
}

kind is the intent discriminator (clarify_answer is the only v1 write kind). The nonce ties the action to the specific delivered card: it is generated and persisted with the delivery record before the send, is reused verbatim on an idempotent re-send of the same card (a re-leased job re-delivers the same nonce), and is invalidated once the clarification reaches a terminal state for that action. A payload replayed against a different clarification id, or carrying a stale/missing nonce, is dropped and audited, never processed.

Algorithms & invariants

Full lifecycle sequence

sequenceDiagram
    autonumber
    participant Worker as Ingestion worker
    participant Plan as plan_ambiguities
    participant Core as ClarifyOps
    participant Graph as Traversal (rank_candidates)
    participant Jobs as Job queue
    participant Bot as clarify-bot
    participant Person as Ranked candidate

    Worker->>Plan: Submission.ambiguities[] (kind, candidates, both confidences)
    Plan->>Plan: validate bounds, confidences, span_refs, interval order
    Plan->>Plan: derive deterministic clarification_id (dedup identity)
    Plan-->>Core: PlannedClarification (applied_interval, span)

    Core->>Core: apply best-guess interval to fact NodeVersion (reduced confidence, pending)
    Core->>Core: severity_for(kind, hint): worker hint elevated by tenant policy, never lowered
    Core->>Core: quorum_for(severity): QuorumPolicy pinned at creation
    Core->>Graph: rank_candidates(target): depth-1 traversal, AUTHORITY_RANKS buckets
    Graph-->>Core: ranked person ids (steward NOT included)
    Core->>Core: create CLARIFICATION node + CLARIFIES edge (or skip: AlreadyExists → dedup)
    Core->>Jobs: enqueue clarify:temporal (ranking, priority by severity)

    Jobs->>Bot: lease (attempt N → ranking[N])
    Bot->>Bot: GET /v1/clarifications/:id (spanExcerpt, aclJson, askedAliases)
    Bot->>Bot: ACL floor check, fail CLOSED on unparseable ACL
    alt candidate fails floor check
        Bot->>Jobs: fail(retryable), reason NO_READ_PERMISSION, skip forward within lease
    else candidate passes
        Bot->>Person: one-tap card (nonce persisted before send)
        Bot->>Bot: stop heartbeating (lease IS the delivery mechanism)
    end

    Person->>Bot: card action {kind: clarify_answer, nonce, answer_index | answer_custom}
    Bot->>Bot: verify signature/JWT FIRST, then ack, then process
    Bot->>Bot: bind chat identity to PERSON (verified alias only)
    Bot->>Core: POST /v1/clarifications/:id/answer (ClarifyService caller, responder asserted)

    Core->>Core: trust resolution (403 RESPONDER_ASSERTION_FORBIDDEN if non-clarify caller asserts another identity)
    Core->>Core: normalise_answer, 422 CLARIFY_UNPARSEABLE_ANSWER if it fails
    Core->>Core: idempotent replay check (same responder+answer to prior outcome)

    alt rights below required role
        Core->>Core: record Attestation, state becomes attested
    else active set disagrees
        Core->>Core: state to conflicted, steward_notified event
    else quorum not yet met
        Core->>Core: state to partially_confirmed, 202 quorum_remaining
    else quorum satisfied or not required
        Core->>Core: re-mark counting responses Bound
        Core->>Core: bind(): new NodeVersion, valid_time = normalised interval, provenance.clarification = id
        Core->>Core: compare embed text before/after to embed_text_changed
        Core->>Core: state to answered
    end

    Core-->>Bot: outcome (bound | partial | conflict | attestation)
    Bot->>Person: card update (per spec §7 copy)

Never silently average conflicting answers

When binding-capable responses disagree, answer moves the clarification to Conflicted and creates no fact version. Spec §5 states this plainly: "quorum is confirmation, not voting." There is no majority, weighted, or averaging path in v1 (OQ-6, resolved 2026-07-04); the only way out of Conflicted is an explicit steward_override, which is itself recorded as an ordinary append-only response (BindingStatus::StewardOverride), never an edit to a prior one.

Answers must come from a clarify-audience token to assert someone else's identity

AnswerCaller::Principal can never carry a person != asserted_responder without erroring. Only AnswerCaller::ClarifyService, which the clarify- bot constructs after it has independently verified the Teams/Slack callback and bound the identity to a PERSON, may name a different responder. This is checked before any read of the clarification record, so a rejected assertion leaves no trace (no response record, no audit row referencing the rejected identity).

Free-form answers never bind unparsed

normalise_answer is the only path from an answer_custom submission to a bound Interval. There is no fallback that stores a raw string as a validity bound; failure to parse is ClarifyError::Unparseable, surfaced to callers as 422 CLARIFY_UNPARSEABLE_ANSWER, and the app layer's "Something else..." UI (a Slack modal with a date picker, per the chat-delivery-surface as-built v0.7) exists specifically so the structured value, not free text, is what reaches this function.

GDPR attribution erasure

erase_person_attribution (core-engine/src/clarify/mod.rs) tombstones a person's attribution on the clarification's current version while preserving every piece of confirmation evidence: asked[] entries containing the person become the nil UUID, matching responses[].responder become nil with message_ref dropped and channel set to "erased", and any occurrence of the person's id inside an AuditEvent.detail string is replaced with "erased". Answer values, response_tx, rights_at_answer_time (the surviving "confirmed by an editor, since erased" marker), binding_status, quorum_state, and fact_version_tx are all left untouched. As-built (temporal-clarification spec §14, v0.9): physical purge of prior node versions referencing the subject, and nil'ing the subject's position in the clarify:temporal job's ranking, is performed separately by the gdpr-erasure executor (PR-081); erase_person_attribution alone covers only the current-version rewrite.

Configuration

From ClarifyConfig (core-engine/src/config.rs), spec §8:

pub struct ClarifyConfig {
    pub enabled: bool,                    // default false; master switch
    pub escalation_window_hours: u64,     // default 48; also the job backoff base
    pub max_candidates: usize,            // default 3; steward attempt not counted
    pub per_person_daily_cap: u32,        // default 5
    pub digest_threshold: u32,            // default 3
    pub digest_window_minutes: u64,       // default 30
    pub redact_fallback: bool,            // default true
    pub channels: String,                 // default "teams,slack"
    pub quorum: ClarifyQuorumConfig,
    pub severity: ClarifySeverityConfig,
}

pub struct ClarifyQuorumConfig {
    pub enabled: bool,                              // default true
    pub critical_required_confirmations: u32,       // default 2
    pub required_roles: Vec<String>,                // default ["editor"]
    pub allow_steward_override: bool,                // default true
}

pub struct ClarifySeverityConfig {
    pub high_notifies_steward: bool,           // default true
    pub elevate: BTreeMap<String, String>,     // e.g. "conflicting_dates" -> "high"
}
Key Default Effect
clarify.enabled false Gates CLARIFICATION creation, clarify:temporal enqueue, and the capability flag advertised to workers
clarify.escalation_window_hours 48 Job backoff base; unanswered past this window advances to the next ranked candidate
clarify.max_candidates 3 Ranking length ceiling (steward attempt is additional)
clarify.per_person_daily_cap 5 Enforced by the delivery surface
clarify.digest_threshold 3 Questions to one person inside the digest window before batching
clarify.digest_window_minutes 30 Digest batching window
clarify.redact_fallback true No candidate passes the floor check → steward gets a redacted question
clarify.channels "teams,slack" Delivery surfaces, first resolvable alias wins
clarify.quorum.enabled true Whether critical severity requires multi-confirmation at all
clarify.quorum.critical_required_confirmations 2 Confirmations required to bind a critical clarification
clarify.quorum.required_roles ["editor"] Minimum rights whose responses count toward quorum
clarify.quorum.allow_steward_override true Whether a steward may bind single-handedly
clarify.severity.high_notifies_steward true Notify the steward when a high-severity question is asked
clarify.severity.elevate {} Kind to minimum-severity elevation map

App-layer configuration (CLARIFY_BOT_CONFIG, chat-delivery-surface spec §8) is documented in full in the clarify-bot operator runbook: per-tenant Teams/Slack credentials, cardSpanMaxChars (280), roles map (deny-by-default RBAC gate), steward aliases, and the ask-in-chat block. This page does not duplicate that content.

Deliberately not configurable

Both specs pin a list of behaviors that are not knobs: the RBAC/ACL gate itself, append-only response and answer recording, the "clarify" audience fence, tenant-scoped identity resolution, audit event emission, the pending-caveat visibility rule, history-preserving correction behavior (overrides always create new versions), DM-only delivery, callback verification, the unmapped-identity attestation rule, and idempotent send. The severity-to-behavior mapping (low pending / normal digest / high direct / critical direct+quorum) is also fixed in v1, not configurable; only the kind-to-severity elevation table is.

As-built notes

From the temporal-clarification spec (§14 changelog)

  • v0.9 (2026-07-06), GDPR gaps closed (PR-081). The two limits recorded in the v0.8 as-built are filled by the gdpr-erasure executor: prior CLARIFICATION node versions referencing the subject are physically purged (the current attribution-tombstoned version survives as the sole remaining version), and the clarify:temporal job payload's ranking has subject positions replaced with the nil UUID (position preserved so attempt indexing stays coherent). erase_person_attribution itself is unchanged; PR-081 replays its semantics end to end. Proven by tests/erasure_e2e.rs::clarify_gdpr_tombstone_extension.
  • v0.9 (2026-07-05), delivery surface end to end (PR-063 as-built cross-reference). Core surface added for delivery: GET /v1/clarifications/:id (span excerpt, source ACL, askedAliases) and the additive JobPayload.attempts field. Escalation-window semantics realized as lease-expiry advancement: the reaper's attempts + 1 plus per-kind backoff (base = escalation window) is candidate advancement, with in-lease skip-forward so a skip never burns a full escalation window.
  • v0.8 (2026-07-05), worker detection + steward surface (PR-061, PR-064). Detection rules landed as addenda to the docling, tabular, and email worker specs. Workers emit no severityHint in v1. PR-064 added telha clarify queue (backed by needs_steward_attention), telha clarify show (delivery history via the job joined on clarification_id), and telha clarify erase-attribution --person.
  • v0.7 (2026-07-05), core loop as-built (PR-060). Notable deviations: dedup identity strengthened to (tenant, source identity, content hash, span, kind); responder rights are asserted directly by "clarify"-audience callers (one trust chain), while API keys gain an optional principal binding and cannot answer without one; one ambiguity per submission node enforced (Provenance.clarification holds a single id); the capability flag is options.clarify = true, with core winning over any worker-supplied value; authority ranking is depth-1 PERSON neighbors bucketed as documented above, capped at max_candidates; REST DTOs use camelCase (answerIndex, messageRef) per house convention, the spec's snake_case wire shapes in §5/§6 are conceptual.
  • v0.6 (2026-07-04), Approved. Responder-assertion condition pinned: only "clarify"-audience service tokens may assert a third-party responder; all other callers bind as themselves; violation is 403 RESPONDER_ASSERTION_FORBIDDEN. Test clarify_answer_responder_assertion added.
  • Earlier versions (v0.1 to v0.5) established the plural response-record model, the severity/quorum split, free-form normalization, and the PR numbering; see the spec file for the full history.

From the chat-delivery-surface spec (§14 changelog)

  • v0.7 (2026-07-06), Slack Block Kit rebuild + grounded ask answer cards. Question cards rebuilt as idiomatic Block Kit via a typed builder with a validateMessageBlocks guard; this fixed a latent bug where an input block was emitted inside a chat.postMessage payload (Slack rejects this; input blocks are modal/Home-tab only). Slack's "Something else..." now opens a modal (views.open to a date picker to view_submission), carrying {clarificationId, nonce, messageRef} in private_metadata so the submission validates against the same delivery record the answer path already checks. A grounded ask-in-chat answer is now delivered as an evidence card (answer, top cited evidence with per-claim verdicts, verification counts, "Open full trace" link); the Teams custom-answer path remains broken (structured answerCustom vs answerCustom:true) and is tracked separately.
  • v0.6 (2026-07-05), PR-070 ask-in-chat. The read-scoped ask handler ships behind ask.enabled (default false; disabled is byte-identical to PR-063 behavior). Identity binding happens first, shared with the answer path; unmapped identities never reach the handler. The clarification write-path invariant holds with ask on: the ask handler has no route to the answer endpoint by construction.
  • v0.5 (2026-07-05), PR-063 as-built. The clarify-bot package landed (15 modules, 44 tests). Notable deviations: the job's lease lifecycle IS the delivery mechanism (no SubmitIngestionResult call); core added the delivery view endpoint and askedAliases; the ACL floor check resolves principals by pinned uuid5 rules and fails closed on parse failure; an unmapped callback identity submits nothing to the answer endpoint (strictly stronger than the spec's original "recorded as attestation" text, since there is no responder id to name); the nonce lifecycle is persisted before send and survives idempotent re-send with the same value.
  • v0.4 (2026-07-04), Approved. Security review conditions incorporated: nonce lifecycle pinned, hook verification ordering made explicit (Slack verify then ack then async; Teams verify then process), per-tenant and per-IP rate limiting on /hooks/*, and the answer-endpoint trust chain cross-referenced against the temporal-clarification spec.

Could not verify against the code

  • Detection rule specifics per worker (docling role-dated conflicts, tabular designated-validity-column readings, email envelope-date checks) live in the docling/tabular/email worker specs, not in core-engine/src/clarify/; this page does not restate them.
  • The Slack Block Kit builder (clarify-bot/src/slack_blocks.ts) and the Teams Adaptive Card rendering live in app-layer/packages/clarify-bot/, outside the code this page was asked to ground against (core-engine/src/clarify/); card-copy details are taken from the specs' §7 sections and the clarify-bot runbook, not independently verified against the TypeScript source.

Companion to Concepts › When Telha is not sure, it asks and the Clarify bot operator runbook.