# Shared policy: resolve-or-escalate

Canonical contract for how skills handle missing or placeholder values on a Jira engagement ticket. The data-quality callers (`cse-intake`, `cse-forecast`, `cse-engagement-finder`, `one-on-one-prep`) invoke this policy instead of defining their own. `war-room-prep` is not a data-quality caller or `cse-dq` writer.

Do not fork this policy per skill. If a skill needs different behavior, change the policy here and update all consumers, or add a new named policy doc next to this one.

## Scope

### Caller intent contract

The caller passes its resolved intent to this policy. The policy is the single
writer of `cse-dq`, so it must preserve the caller's mode rather than creating
an independent write path:

| Caller mode | Policy behavior |
| --- | --- |
| `REPORT` | Do not invoke the policy. The caller reports findings and recommendations only. |
| `PREVIEW` | Read field, source, and existing-property state; run guarded dry-runs for each proposed field, comment, or property mutation; retain preview digests; never apply a mutation. |
| `EXECUTE` | Preview every mutation, apply only with its preview digest, then GET-back the Jira issue, newest comment where applicable, and `cse-dq` property before recording the receipt. |

PREVIEW is safe because it may use policy reads and guarded write dry-runs, but
it must never set a field/property or post a comment. EXECUTE may not reuse a
digest from another ticket, capability, or mutation payload.

**Inputs**
- A Jira ticket (CSE project) -- issue key and status name (read from the issue via `cse_read` with capability `jira_get_issue`; the daemon owns field mapping).
- A list of semantic field names to check (for example `executive_sponsor`, `technical_counterpart`, `problem_statement`, `impact_metrics`). Discover the current list through `cse_read` with the `cse_domain_info` capability:
  ```js
  cse_read({ "capability": "cse_domain_info", "arguments": {} })
  ```

**Outputs (EXECUTE only)**
- Jira field writes for confidently-resolved values.
- One evidence comment per resolved field.
- At most one batched escalation comment per invocation, referencing the escalation target by display name (assignee fallback today; see "Who is the account team").

**Out of scope**
- Stage transitions. This policy never owns stage transitions: `cse-forecast` is the sole portfolio/board-forecast transition owner (EXECUTE mode only), while other skills may perform transitions explicitly scoped to their own workflows.
- `stage_complete` checkbox changes outside `cse-forecast` EXECUTE mode.
- Direct messages to humans. (Reserved for a future `cse-stalled-nudger` skill.)
- Any writes outside the target ticket.
- Direct `cse-dq` property writes from skills other than this policy.

## Sentinel classification

Classification is owned by the daemon, not the agent. Route the read capability through `cse_read` once per field with only the implemented params (`field`, `raw_value`, `status`):

```js
cse_read({
  "capability": "cse_resolve_field",
  "arguments": {
    "field": "executive_sponsor",
    "raw_value": "<current value or ADF JSON string>",
    "status": "<ticket status name>"
  }
})
```

The daemon does, deterministically and exactly once:

- For ADF fields (`problem_statement`, `impact_metrics`, `postman_demonstration`, `customer_assets`), extract plain text from the ADF JSON (concatenate every `text` node with single spaces between siblings; ignore media/mentions), trim, then test. Empty extracted text counts as missing.
- Case-insensitive exact match after trim against the configured `sentinel_values` (`sentinel_type: "value"`).
- Regex match against the configured `sentinel_patterns` using each pattern as written (`new RegExp(pattern)` -- case sensitivity follows the pattern; patterns are not auto-`i`-flagged) (`sentinel_type: "pattern"`).
- Stage-gate: `skip_escalation` is true when `status` is in the configured `skip_escalation_stages`.
- Role list: `escalation_roles` from `escalation_policy[<field>].roles`.

It returns:

```json
{
  "field": "executive_sponsor",
  "is_adf": false,
  "is_missing": true,
  "sentinel_type": "value",
  "adf_text": null,
  "skip_escalation": false,
  "escalation_roles": ["owner", "csm"],
  "escalation_team": null,
  "next_actions": ["find_source_evidence", "update_jira_field"]
}
```

`is_missing` is the verdict. `sentinel_type` is `"empty"` when the value is blank after ADF/plain extraction, `"value"` when it matches a configured sentinel value, `"pattern"` when it matches a configured sentinel regex, or `null` when the field is present. There is no `kepler_available` field on this response. `escalation_team` is currently always `null` -- the helper does not perform Team-ID / Kepler account-team lookup. Fall back to the assignee (see "Who is the account team" below). `next_actions` is `["find_source_evidence", "update_jira_field"]` only when `is_missing` is true **and** `skip_escalation` is false; otherwise it is `[]` (including when the field is missing but stage-gated). Follow those hints unless you have a reason not to.

Do not re-implement sentinel matching, ADF extraction, or the stage-gate skip in agent code. Do not read `~/.cse-tools/config.yaml` to get sentinel lists or field metadata -- the daemon already validated and applied them. Do not pass `team_id` into `cse_resolve_field` and do not invent a `list_customers(team_id=...)` call for escalation targets.

## Resolution chain

Primary path is served by Kepler tools aggregated inside the signed local `cse-tools` MCP.

Try in order; stop at the first source that yields a named, titled match. If none do, mark the field unresolved.

1. **Gong** (local `cse-tools` MCP Kepler capabilities) -- within the last `data_quality.gong_lookback_days` (90) days.
   - Route `list_customers` through `cse_read` with `search=<account name>` to resolve `account_id`.
   - Route `list_communications` through `cse_read` with `account_id=<account_id>`, `start_at=<ISO 90d ago>`, and `limit=20` to enumerate calls / emails.
   - Route `get_communications` through `cse_read` with `ids=[<id>]` and `include_content="full"` to retrieve the transcript or body.
   - Accept participants whose title, role, or observed project ownership matches the field's confidence criteria (see `confident_match_criteria` below).

2. **Salesforce** -- route `get_salesforce_account` through `cse_read` with `account_id=<account_id>`. Read customer-side exec-sponsor and technical-contact fields only; internal account-team roles (AE, CSM, ADR, Solution Engineer) are not valid customer-side values. Accept a named contact as authoritative only if the person still appears in a comms source within 365 days (stale-contact protection).

3. **Slack** -- route `slack_search` through `cse_read`. Accept only posts authored by an internal Postmanaut (not a customer-domain user) that name the person *and* state the title explicitly (for example "Ram Marupudi, VP Architecture"). Unstructured mentions without title do not count.

### Known Kepler gap: team hierarchy

`list_team_communications` and team-scoped `resolve_subject` queries rely on a team-membership index that is not reliably synced. In practice both return empty even when the individual members have activity.

Do NOT use team-scoped queries as a primary shape. Resolve per-person, then batch N parallel `cse_read` calls for the `list_communications` capability with `participant_email=<email>` instead.

If Kepler remains unavailable, the field is unresolved for this run. Do not retry further; escalation handles it.

### Confident match criteria

Named + titled, **where the title / role / observed ownership matches the field**:

| Field | Acceptable titles |
|---|---|
| `executive_sponsor` | Customer-side buyer, sponsor, senior stakeholder, or influential platform / engineering leader with enough organizational pull to sponsor the initiative. VP+ is strong evidence but not required; senior directors, senior managers, or platform owners can qualify when account context and comms suggest they can move budget, prioritization, or adoption. |
| `technical_counterpart` | Platform / SRE / DevOps / SCM / CI-CD / Architecture lead; GitLab / GitHub / Bitbucket admin; API platform owner. Individual contributor acceptable if role matches. |
| `problem_statement` | Free-text; "confident" means a paragraph, not a phrase. |
| `impact_metrics` | Numeric target + timeframe; "confident" requires both. |

Anything short of the confidence criteria is unresolved, even if the source is authoritative. For `executive_sponsor`, do not reject a candidate purely because the title is below VP. If the best candidate appears to have practical organizational pull but the evidence is imperfect, resolve with a caveat and ask for confirmation; if the candidate is just the highest-title person with no project connection, leave it unresolved and escalate with the guess.

### Conflict resolution

When two or more sources propose different people for the same field, prefer in this order: **Gong (90d) > SF > Slack**. Gong wins because it is dated, recency-verifiable evidence; SF goes stale silently; Slack is least structured.

If two sources at the same level propose different people (for example two Gong calls, different participants), do not auto-resolve. Escalate with both candidates named.

## Write: field update, cse-dq property, comment

Every mutation in this section uses the two-phase write contract. Preview and apply both go through `cse_apply`, which always requires outer `execute: true` and a justification of at least 16 trimmed characters -- there is no outer dry-run on `cse_apply`. Preview first with the mutation arguments and **omit** underlying `arguments.execute` (so the named write dry-runs and returns `preview_digest`). In PREVIEW mode, stop there and return the proposed action plus digest. In EXECUTE mode, call `cse_apply` with the same capability, the same mutation args plus underlying `arguments.execute: true` and the returned `arguments.preview_digest`, outer `execute: true`, and justification. Never skip the preview phase. Property writes (`jira_property` set/delete) are digest-gated: always capture the preview's `preview_digest` and pass it on apply.

For each confidently-resolved field:

1. **Update the Jira field** through the `jira_edit_issue` capability.
   - If the write fails with a transient error (5xx, 429, connection reset), wait 5 seconds and retry once. A second failure is treated as unresolved for escalation purposes.
2. **Write the evidence to the `cse-dq` entity property.** See [`.agents/shared/audit-manifest.md`](audit-manifest.md) for the property schema. Evidence (source tool, ISO date, permalink, resolving author) is audit-surface; keep it on the property and out of the UI.
3. **Post a comment on the ticket** through the `jira_add_comment` capability, using the prose-voice `Resolve` templates defined in [`.agents/shared/prose-voice.md`](prose-voice.md).
   - If caveat-free and a gut-check target exists, use **Resolve -- high-confidence field write** (required `gut_check_mention`).
   - If the resolution has a caveat (stale signal, soft conflict, partial match), use **Resolve -- with caveat** (optional `gut_check_mention`); the caveat is the value-add.
    - Never emit `Auto-`, `Source:`, ISO dates, permalinks, or HTML comments in the comment body -- all are anti-patterns per prose-voice.md. That data lives on the `cse-dq` property.
4. **GET-back before receipt.** After every applied field write, read the Jira issue and verify the semantic field. After every applied comment, read the newest comments and verify the posted comment. After every applied property update, read `cse-dq` and verify the preserved entry. A failed GET-back is `HELD`, not a successful receipt.

**Gut-check target selection** (who to reference by display name in the Jira comment):
- `executive_sponsor` / `technical_counterpart` resolved: reference the ticket assignee by display name.
- `problem_statement` / `impact_metrics` resolved: reference the reporter by display name if distinct from assignee.
- If no gut-check target can be resolved, **skip the comment entirely** -- the field change is visible in the Jira activity log and the evidence is on the property. A no-mention comment is noise.

**Source permalink formats** (recorded on the `cse-dq` property):
- Gong: `https://us-14496.app.gong.io/call?id=<id>`
- Salesforce: the `sf_record_permalink` returned by `get_salesforce_account`
- Slack: the `permalink` field on the thread / channel read result

## Escalation

After resolution attempts finish, batch all still-unresolved fields into a single comment per ticket, then reference the escalation target by display name.

### Stage gate

The `cse_resolve_field` capability returns `skip_escalation: true` when the ticket's current status is in the configured `skip_escalation_stages` (default: `Submitted`, `Qualification Review`). When true, skip the escalation comment entirely. Resolution is still attempted (cheap, and pre-populating fields helps the assignee) -- only the escalation comment is suppressed.

Rationale: at these stages the assignee is still filling the ticket out by hand. Escalating immediately would ping the AE on work they are already doing.

Skills that deliberately hold escalation through early stages (for example `cse-intake` before Technical Discovery) MUST re-invoke this policy once the ticket leaves the skip stages so the formerly suppressed escalation can post under the property cooldown/idempotency rules below.

### Who is the account team

`escalation_roles` names the configured role set for the field (for example `owner`, `csm`). `escalation_team` is currently always `null` -- the helper does not look up Team ID or resolve Kepler account-team users. Do not invent a `list_customers(team_id=...)` call and do not pass `team_id` into `cse_resolve_field`.

**Fallback (current path):** reference the ticket's current assignee by display name. If the assignee field is also empty, reference the most recent commenter on the ticket and say so inline. Do not wait on Kepler for escalation targets -- the helper does not expose Kepler readiness on this response.

### Comment shape

Use the **Escalate -- batched missing fields** template from [`.agents/shared/prose-voice.md`](prose-voice.md). The template picks its form based on `unresolved_fields` count (1 vs 2+).

Context to pass:
- `unresolved_fields`: list of `{name, reason}` objects. Reason should be short and specific. Examples:
  - "Maya Rao is the closest sponsor guess from history, but she does not appear in project comms."
  - "Gong 2026-04-09 names Mike Cahill; Salesforce names Dan Salvo. Two candidates."
  - "Field is placeholder; no signal in any source."
- `mentions`: display names for the escalation target selected above (Jira plain ADF references people by display name only).

**Never** inline ISO dates, permalinks, `Source:` blocks, HTML comment markers, or bulleted lists in the comment body. Those are prose-voice anti-patterns. Evidence and state live on the `cse-dq` property (see [`audit-manifest.md`](audit-manifest.md)), not the comment.

### Idempotency

Before posting the escalation comment:

1. Use the canonical key derivation in [`audit-manifest.md`](audit-manifest.md), exactly: `key = sha256(ticket_id + "|" + type + "|" + sorted(fields).join(",") + "|" + (source.tool || "none")).slice(0, 8)`. For this action, `type = "escalated"`, `fields = sorted(unresolved_field_names)`, and `source.tool = "none"` for key derivation only.
2. Read the `cse-dq` entity property with `cse_read({ "capability": "jira_property", "arguments": { "action": "get", "issueIdOrKey": ticket_id, "propertyKey": "cse-dq" } })`. If absent, initialize `{ "v": 1, "entries": {} }` in memory; reject unknown versions.
3. If `entries[key]` exists and its `cooldown_until` is later than now, skip posting. Read `data_quality.cooldown_days` (default 7) with `cse_read({ "capability": "cse_domain_info", "arguments": {} })`.
4. Otherwise post the escalation comment through the two-phase write contract. Preview first (omit underlying `execute` so the named write dry-runs), then apply:

    ```js
    cse_apply({
      "capability": "jira_add_comment",
      "arguments": {
        "issueIdOrKey": ticket_id,
        "body_path": "/tmp/cse-escalate.md"
      },
      "execute": true,
      "justification": "Preview the unresolved field escalation comment"
    })
    cse_apply({
      "capability": "jira_add_comment",
      "arguments": {
        "issueIdOrKey": ticket_id,
        "body_path": "/tmp/cse-escalate.md",
        "execute": true,
        "preview_digest": "<preview_digest from preview>"
      },
      "execute": true,
      "justification": "Escalate unresolved data-quality fields to the account team"
    })
    ```

    Read `cse-dq` again, preserve all existing entries, and set `entries[key]` to the canonical entry shape before writing the whole updated property:
    ```json
    {
      "type": "escalated",
      "fields": ["<sorted_field_1>", "<sorted_field_2>"],
      "at": "<ISO-8601 UTC>",
      "confidence": "low",
      "actor": "<Atlassian account ID>",
      "cooldown_until": "<at + data_quality.cooldown_days>"
    }
    ```
 5. Write the property via the two-phase contract. Preview omits underlying `execute` and returns `preview_digest`; apply repeats the mutation with outer `execute: true`, justification, underlying `arguments.execute: true`, and the preview's `preview_digest`:

    ```js
    cse_apply({
      "capability": "jira_property",
      "arguments": {
        "action": "set",
        "issueIdOrKey": ticket_id,
        "propertyKey": "cse-dq",
        "value": updatedValue
      },
      "execute": true,
      "justification": "Preview the escalation cooldown entry update"
    })
    cse_apply({
      "capability": "jira_property",
      "arguments": {
        "action": "set",
        "issueIdOrKey": ticket_id,
        "propertyKey": "cse-dq",
        "value": updatedValue,
        "execute": true,
        "preview_digest": "<preview_digest from preview>"
      },
      "execute": true,
      "justification": "Record the escalation cooldown entry on cse-dq"
    })
    ```

    Prune expired entries as specified by the canonical manifest contract.


Rationale: the entity property is API-only and invisible in the UI. Cooldown state kept in the comment body would clutter the human-visible surface and violate the prose-voice contract (no HTML-comment metadata in comment bodies). When a field is filled and then goes blank again (rare), the key is different because the sorted field list is different, so the 7-day cooldown does not silence it.

## Execution identity

Whoever invoked the skill is the author of record for writes and comments. This is acceptable for interactive invocations (today's usage).

A cron or background invocation would need a dedicated service account. That is out of scope for this policy; cron-driven skills such as `cse-stage-gate-nudger` must establish a service account before they ship.

## Integration points

Each skill calls the policy at exactly one point in its flow (except `cse-intake`, which re-invokes once after Technical Discovery so held escalations can post):

| Skill | Call site | Fields requested |
|---|---|---|
| `cse-intake` | After Jira ticket creation (resolution; escalation held while status is skip-gated), then again after the ticket reaches Technical Discovery for still-unresolved fields. | `executive_sponsor`, `technical_counterpart`, `problem_statement` |
| `cse-forecast` | Section 13 "data quality" on open tickets: REPORT lists findings only; PREVIEW runs policy reads and guarded dry-runs only; EXECUTE applies with digest and GET-back. | `executive_sponsor`, `technical_counterpart` (open tickets only) |
| `cse-engagement-finder` | Before promoting a Bucket A target to auto-intake. | `executive_sponsor`, `technical_counterpart` |
| `one-on-one-prep` | When pulling ticket context for each direct report. | `executive_sponsor`, `technical_counterpart` |

`war-room-prep` is absent from this table on purpose: builder war-room prep is not a data-quality caller or `cse-dq` writer.

Skills must pass the current status name so the stage gate can be evaluated. Skills must not bypass this policy with their own fallback research; if the policy says unresolved, the skill reports unresolved.
