# Audit manifest: Jira entity-property ledger

Canonical contract for where data-quality evidence, cooldown state, and tool-call traces live now that the comment body is reserved for clone-voice prose.

## Why a property

Evidence-in-comment-body breaks clone fitness. Humans reading the ticket see "Auto-resolved technical_counterpart: Ram Marupudi. Source: Gong call 2026-04-09, https://..." and instantly clock it as bot output. Moving the evidence off the human-visible surface restores the prose budget.

**Jira entity properties** -- `/rest/api/3/issue/{key}/properties/{propertyKey}` -- store arbitrary JSON keyed on the issue. API-only: invisible in the Jira UI, activity log, search, CSV export. Nobody sees it unless they explicitly query the endpoint.

Alternatives considered and rejected:

| Option | Why rejected |
|---|---|
| Repo file `.cse-tools/state/*.json` | Breaks as soon as another user (Eric, Roman) runs the skills, or a remote cron box runs them. Jira is already authoritative -- don't duplicate. |
| Hidden custom field | Requires Jira admin to create; visible in CSV export anyway. |
| Namespaced labels (`_dq_3a2f1b`) | Visible in the label field. Humans notice. Pollutes a field with legitimate use. |
| SQLite / local KV | Same multi-context problem as repo files. No per-ticket query. |
| Dedicated "bot ledger" issue | Grows unbounded; harder to query per-ticket; still Jira-visible. |
| Redis / remote KV | New infra dependency for a tiny amount of state. |

## Property key: `cse-dq`

One property per issue. Written only by the shared `resolve-or-escalate.md` policy when it auto-resolves a data-quality field or issues a batched escalation.

### Schema (v1)

```json
{
  "v": 1,
  "entries": {
    "<sha256_key>": {
      "type": "resolved|escalated|held",
      "fields": ["technical_counterpart"],
      "at": "2026-04-18T03:11:02Z",
      "source": {
        "tool": "gong|salesforce|slack|none",
        "url": "https://us-14496.app.gong.io/call?id=4201813702",
        "date": "2026-04-09",
        "participant": "Ram Marupudi, VP Architecture"
      },
      "confidence": "high|medium|low",
      "actor": "5a1b2c3d4e5f6g7h8i9j0",
      "cooldown_until": "2026-04-25T03:11:02Z"
    }
  }
}
```

Field definitions:

| Field | Required | Shape | Purpose |
|---|---|---|---|
| `v` | yes | integer | Schema version. Bump for incompatible changes. Readers must reject unknown versions. |
| `entries` | yes | object | Keyed on the canonical stable SHA derived below. |
| `entries[key].type` | yes | enum | `resolved` = we wrote a field value; `escalated` = we posted an escalation comment referencing the account team for missing fields; `held` = action skipped due to H1/H2/H3. |
| `entries[key].fields` | yes | string[] | Semantic field names (the keys under `jira.custom_fields`, e.g. `technical_counterpart`; discoverable via `cse_domain_info`). For `escalated`, sorted lexicographically so the key is stable. |
| `entries[key].at` | yes | ISO 8601 UTC | When the entry was written. |
| `entries[key].source` | if `type=resolved` | object | Only set for `resolved`. Evidence origin. |
| `entries[key].source.tool` | yes (for resolved) | enum | Which source in the resolve-or-escalate chain produced the value. |
| `entries[key].source.url` | yes (for resolved) | string | Permalink. Required -- no permalink, no source. |
| `entries[key].source.date` | yes (for resolved) | ISO date | When the evidence itself was produced (Gong call date, SF record update, etc.). |
| `entries[key].source.participant` | optional | string | For `executive_sponsor` / `technical_counterpart`: the named, titled person. |
| `entries[key].confidence` | yes | enum | `high` = named + titled + recent; `medium` = named + titled but stale (>90d Gong); `low` = inferred from weak or absent signal. Only `high` should proceed to a Jira field write; `escalated` entries use `low`. |
| `entries[key].actor` | yes | string | Atlassian account ID that triggered this write. For automation, the bot's service-account ID. |
| `entries[key].cooldown_until` | yes | ISO 8601 UTC | `at` + `data_quality.cooldown_days`. Used by the cooldown check to suppress re-escalation. |

### Key derivation

The `<sha256_key>` is a stable dedupe key so the cooldown check can look up the same logical action across runs.

```
key = sha256(ticket_id + "|" + type + "|" + sorted(fields).join(",") + "|" + (source.tool || "none")).slice(0, 8)
```

The 8-character hex is readable while remaining stable. Collisions are benign -- the worst case is one cooldown suppresses another; the loss is at most 7 days of noise.

## Lifecycle

### Read-before-write

The shared policy, before it writes to `cse-dq` or posts a resolution/escalation comment:

1. Call `cse_read({ "capability": "jira_property", "arguments": { "action": "get", "issueIdOrKey": ticket, "propertyKey": "cse-dq" } })`; an absent property is reported as not found.
2. If undefined, initialize `{ v: 1, entries: {} }` in memory.
3. If `v !== 1`, halt -- property was written by a newer writer; do not clobber.

### Cooldown check

Before posting an escalation comment OR rewriting a resolved-field comment:

1. Compute `key` per the derivation rule.
2. If `entries[key]` exists AND `entries[key].cooldown_until > now`: suppress the write. Skip the write silently; no per-call trace is emitted.
3. Otherwise: proceed with the write, then update the property (see below).

### Post-write update

After a successful Jira field update (for `resolved`) or escalation comment post (for `escalated`):

1. Read `cse-dq` again (it may have changed between the initial read and now).
2. Preserve the current `entries` object and set only `entries[key] = { type, fields, at: now, source?, confidence, actor, cooldown_until: now + cooldown_days }`; never replace or discard unrelated entries.
3. **Prune**: remove any `entries[k]` where `cooldown_until < now - 14d`. Keeps the property from growing unbounded.
4. Write via the two-phase contract. `cse_apply` always requires outer `execute: true` and a justification of at least 16 trimmed characters -- there is no outer dry-run on `cse_apply`. Preview the named write first (omit underlying `execute` so it dry-runs and returns `preview_digest`); then apply with the same capability and mutation args plus underlying `arguments.execute: true` and the preview's `arguments.preview_digest`. Property set/delete is digest-gated -- always capture and carry the digest. Real params only: `action`, `issueIdOrKey` (or `key` alias), `propertyKey`, `value` (for set), `execute`, `preview_digest`, plus outer justification:

    ```js
    cse_apply({
      "capability": "jira_property",
      "arguments": {
        "action": "set",
        "issueIdOrKey": ticket,
        "propertyKey": "cse-dq",
        "value": updatedValue
      },
      "execute": true,
      "justification": "Preview the data-quality audit entry on cse-dq"
    })
    cse_apply({
      "capability": "jira_property",
      "arguments": {
        "action": "set",
        "issueIdOrKey": ticket,
        "propertyKey": "cse-dq",
        "value": updatedValue,
        "execute": true,
        "preview_digest": "<preview_digest returned by dry-run>"
      },
      "execute": true,
      "justification": "Persist the data-quality audit entry on cse-dq"
    })
    ```

### Pruning cadence

Prune on every write. Cost is O(n) in entry count, which is bounded: a single ticket rarely sees more than ~10 distinct dedupe keys over its lifetime.

## Skill integration

| Skill | Reads via `jira_property` before | Sets via `jira_property` after |
|---|---|---|
| `cse-forecast` | Shared policy before every escalation or field-resolution comment (PREVIEW/EXECUTE only) | Shared policy after every successful field write or escalation post (EXECUTE only) |
| `cse-intake` | Shared policy before repair-mode field writes | Shared policy after successful repair field updates |
| `cse-engagement-finder` | Shared policy during pre-intake readiness check (if promoting from Bucket A) | Shared policy after intake ticket creation |
| `one-on-one-prep` | Shared-policy field presence audit during ticket summary | N/A (read-only for summary) |

`war-room-prep` is absent from this table: it is not a board data-quality caller and must not read or write `cse-dq`.

`resolve-or-escalate.md` is the single property writer: each listed skill invokes the shared policy, which owns the property read/write. Skills do not touch `cse-dq` directly.

## Permissions

Entity properties are readable/writable by any user with edit permission on the issue. The skill operates under the user's Atlassian identity; no extra permission scope required.

For future bot-identity work (cron-invoked skills, Slack-triggered automation), the dedicated service account must have:

- `BROWSE_PROJECTS` on the CSE project
- `EDIT_ISSUES` on the CSE project

Atlassian does NOT expose a per-property permission scope. Granting edit-issue access is sufficient.

## Privacy

`cse-dq` values are API-only but not secret. Anyone with Jira read access to the CSE project can fetch the property. Do not store:

- Passwords, tokens, API keys
- Free-text from Gong transcripts (too much latitude for sensitive content)
- Salesforce opportunity amounts, contract terms, or internal revenue data

Safe to store:

- Names + titles of resolved participants (already visible in ticket comments today)
- Public meeting permalinks (Gong call URLs, SF record URLs)
- ISO timestamps, enum values, SHA hashes, account IDs

## Versioning

Changes to the schema bump `v`. The v1 -> v2 migration contract:

1. A reader that encounters `v > max_supported` aborts rather than clobbering.
2. The first writer to bump the version must migrate existing entries in-place (old schema -> new schema) in the same PUT.
3. No writer may downgrade `v`.

## Testing

Shared unit tests cover:

- `jira_property` with `action: "get"` returns the unwrapped `value`
- `jira_property` with `action: "get"` reports an absent property without throwing
- `jira_property` with `action: "set"` sends the JSON value as-is (no ADF wrapping)
- `jira_property` with `action: "delete"` sends no value body

Cooldown math + key derivation is tested inside `resolve-or-escalate` consumers; this contract doc is the source of truth for the algorithm.

## Out of scope

- Cross-ticket aggregation (per-account dashboards). Entity properties are per-issue; a dashboard would need to scan all tickets in a project, which is expensive. If needed, build a separate dashboard-cache.json under `~/.config/cse-tools/logs/` off a nightly job.
- Real-time notifications. The property is passive state; it does not trigger webhooks or subscriptions.
- Audit across customer teams. Each CSE project ticket's property is scoped to that ticket. There is no tenant-wide audit surface in this design.
