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:
- Normalize the answer (
normalise_answer): exactly one ofanswer_indexoranswer_customis required. An index answer resolves againstclar.candidates; a custom answer parsesvalid_from/valid_to(µs int or RFC3339 string) into anInterval. Anything that cannot resolve to a structured interval returnsClarifyError::Unparseable, surfaced as422 CLARIFY_UNPARSEABLE_ANSWER. - 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. - Post-binding path: if the clarification is already
Answered, an agreeing duplicate from a new responder is409 CLARIFY_ALREADY_ANSWERED; a disagreeing one is recorded as aConflictingattestation, asteward_notifiedaudit event fires, and the bound correction is left untouched (AnswerOutcome::Conflict { post_binding: true }). - Attestation path: if the responder's
rights_at_answer_timeis below the policy's minimum required role, the response is recorded withBindingStatus::Attestationand the state advances toAttested(if it wasOpen). It counts toward nothing until an editor or steward confirms it. - Steward override path: an explicit
steward_overrideflag (never implicit) requiresRights::Stewardandquorum_policy.allow_steward_override; it recordsBindingStatus::StewardOverrideand binds immediately. - 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 entersConflictedand asteward_notifiedevent fires: no correction is created. If they agree but the count is belowrequired_confirmations, the state becomesPartiallyConfirmedand the answer returnsPartialConfirmation { quorum_remaining }. If the count meets or exceeds the requirement, the counting responses are re-markedBoundandbindis 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
CLARIFICATIONnode versions referencing the subject are physically purged (the current attribution-tombstoned version survives as the sole remaining version), and theclarify:temporaljob payload'srankinghas subject positions replaced with the nil UUID (position preserved so attempt indexing stays coherent).erase_person_attributionitself is unchanged; PR-081 replays its semantics end to end. Proven bytests/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 additiveJobPayload.attemptsfield. Escalation-window semantics realized as lease-expiry advancement: the reaper'sattempts + 1plus 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
severityHintin v1. PR-064 addedtelha clarify queue(backed byneeds_steward_attention),telha clarify show(delivery history via the job joined onclarification_id), andtelha 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.clarificationholds a single id); the capability flag isoptions.clarify = true, with core winning over any worker-supplied value; authority ranking is depth-1 PERSON neighbors bucketed as documented above, capped atmax_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 is403 RESPONDER_ASSERTION_FORBIDDEN. Testclarify_answer_responder_assertionadded. - 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
validateMessageBlocksguard; this fixed a latent bug where aninputblock was emitted inside achat.postMessagepayload (Slack rejects this; input blocks are modal/Home-tab only). Slack's "Something else..." now opens a modal (views.opento a date picker toview_submission), carrying{clarificationId, nonce, messageRef}inprivate_metadataso 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 (structuredanswerCustomvsanswerCustom: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
SubmitIngestionResultcall); core added the delivery view endpoint andaskedAliases; 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 inapp-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.
Related¶
Companion to Concepts › When Telha is not sure, it asks and the Clarify bot operator runbook.
- Concepts › The tritemporal model - the append-only correction a resolved clarification performs.
- The version record model - the
update_nodewrite pathbind()calls into. - SSO & authentication - the Entra-verified identity that responder binding depends on.
- Clarify bot runbook - deploying and operating the Teams/Slack delivery surface,
CLARIFY_BOT_CONFIG, and the CLI.