Once an annotation is a first-class object, it can carry who wrote it, on what basis, and how it ages. Two optional blocks attach to any annotation — provenance and lifecycle — without disturbing its existing fields. Existing annotation arrays keep working unchanged. The helpers live in semiotic/ai; the types are re-exported from the main semiotic entry.
This matters most when an annotation is left by an AI or an autonomous watcher rather than a person, and when the chart is a durable, shared surface that accumulates interpretation over time — the conversational substrate described inInterrogation andConversation Arc. The schema is the union of Semiotic's shipped fields and theChartAnnotationProvenance shape from the Intent-Driven Information Design framework.
JSX
import { withProvenance } from "semiotic/ai" const annotated = withProvenance( { type: "callout", x: "2026-W14", y: 9, label: "Deploy-correlated spike" }, { provenance: { authorKind: "watcher", // who: human | agent | watcher | system author: "latency-detector", basis: "statistical-test", // how it was derived (distinct from "who") confidence: 0.78, createdAt: "2026-04-02T14:32:00Z", dataVersion: "2026-W14", stableId: "deploy-spike-2026-w14", }, lifecycle: { ttlHint: "P14D", // fresh for two weeks, then ages status: "proposed", // editorial standing (unreviewed) anchor: "semantic", // re-resolve by stableId on data refresh }, } )
Provenance — where a note came from
Provenance answers three separate questions that the field often collapses into one: who created the annotation (authorKind), how its claim was derived (basis), and a coarse origin label (source). A "human" author can relay a "statistical-test" basis, so keeping the actor and the evidence-type separate lets a reader — or an agent — weight a note by the strength of its evidence, not just who left it.
| Field | Type | Meaning |
|---|
| author | string | Display name or id of the creator. |
| authorKind | "human" | "agent" | "watcher" | "system" | … | The actor category (open union). |
| source | "user" | "ai" | "import" | … | Coarse origin label, kept for back-compat. |
| basis | "human-note" | "statistical-test" | "rule" | "llm-inference" | "external-source" | … | Evidence type — how the claim was derived. |
| confidence | number (0–1) | Confidence in the assertion. Hand-placed ≈ 1; LLM-suggested often < 0.8. |
| createdAt | ISO 8601 string | When the annotation was created (drives freshness). |
| dataVersion | string | The data snapshot the note was made against. |
| stableId | string | Opaque id that survives data refresh; target of semantic anchoring and supersedes. |
Lifecycle has two orthogonal axes
"How does this note age?" is two questions, not one, and they are independent — a note can be fresh but disputed, orstale but accepted. Semiotic models both on thelifecycle block.
| Axis | Field | Question it answers | Driven by |
|---|
| Temporal | freshness | Does this note still apply as the data ages? | createdAt + ttlHint |
| Editorial | status | Is this note accepted or contested by the people and agents arguing about it? | the accept / dispute / retract flow |
Temporal freshness
computeAnnotationFreshness classifies each annotation into a band from its createdAt and ttlHint relative to now; applyAnnotationLifecycle does that and applies a default visual treatment in one pass. Both are pure and SSR-safe. For streaming / time-series charts, pass dataExtent so the latest data point becomes now — freshness then tracks chart-time, not wall-clock.
| Band | Threshold (× TTL) | Default visual treatment |
|---|
| fresh | < 1× | no change |
| aging | 1× – 1.5× | opacity 0.55 |
| stale | 1.5× – 3× | opacity 0.35 + dashed (4 4) |
| expired | ≥ 3× | filtered out (or opacity 0.2 + dashed 2 4 with showExpiredAnnotations) |
JSX
import { applyAnnotationLifecycle, currentTimestamp, withCurrentProvenance } from "semiotic/ai" // Stamp the moment a watcher adds a note… const note = withCurrentProvenance( { type: "y-threshold", value: 90, label: "Latency SLA" }, { authorKind: "watcher", basis: "rule" } ) // …then age the whole array against the chart's current time extent. const treated = applyAnnotationLifecycle([note], { dataExtent: data.map(d => d.time), // latest point becomes "now" }) <LineChart data={data} xAccessor="time" yAccessor="value" annotations={treated} />
Per-band defaults are overridable (pass opacity,strokeDasharray, or labelSuffix maps; set a band to null to disable its default). An annotation's ownopacity / strokeDasharray always win over the treatment.
This is one of three temporal systems in Semiotic — seeTemporal Lifecyclefor how annotation freshness relates to streaming decay andstaleness. They share the bandFromAgeprimitive but act on different time axes.
Editorial status
A conversational chart accumulates truth, error, speculation, and institutional folklore. status is the mechanism for contesting a note: a watcher leaves it "proposed", a human marks it "accepted" or "disputed", and"retracted" withdraws it. supersedes links a revision to the stableId of the note it replaces, forming a revision chain.
| status | Meaning |
|---|
| "proposed" | Placed but unreviewed (e.g. an autonomous watcher's note). |
| "accepted" | Confirmed by a human or agent. |
| "disputed" | Contested; under review. |
| "retracted" | Withdrawn; treated like an expired note. |
applyAnnotationStatus turns that editorial standing into a default visual treatment — the orthogonal companion toapplyAnnotationLifecycle's temporal one. Freshness, importance (M1 emphasis), and editorial status are three independent dimensions of the same render: a note can be stale-and-disputed. Run freshness first, then status, and the opacity composes (it multiplies in).
| status | Default visual treatment |
|---|
| "accepted" | no change (full weight) |
| "proposed" | opacity ×0.7 + dashed (3 3) + label suffix “ (proposed)” |
| "disputed" | opacity ×0.7 + dashed (2 3) + label suffix “ (?)” — the query affordance |
| "retracted" | filtered out (or opacity ×0.25 with showRetractedAnnotations) |
It also resolves supersession: a note whosestableId is the supersedes target of another present, non-retracted note is hidden — the revision replaced it — unless you pass showSupersededAnnotations.
JSX
import { applyAnnotationStatus, applyAnnotationLifecycle } from "semiotic/ai" // Temporal first, then editorial — opacity composes (multiplies) across both. const treated = applyAnnotationStatus( applyAnnotationLifecycle(annotations, { dataExtent: data.map(d => d.time) }) ) <LineChart data={data} xAccessor="time" yAccessor="value" annotations={treated} />
Below, four notes carry four statuses. The disputed note wears its(?) query affordance, the proposed watcher note reads provisionally, the retracted note is gone, and the accepted note paints at full weight.
import { LineChart } from "semiotic" const frameProps = { /* --- Data --- */ data: [{ week: 1, score: 12 }, { week: 2, score: 18 }, ... ], /* --- Process --- */ xAccessor: "week", yAccessor: "score", /* --- Customize --- */ xLabel: "Week", yLabel: "Score", /* --- Annotate --- */ annotations: applyAnnotationStatus([ { type: "callout", x: 4, y: 24, label: "Confirmed peak", lifecycle: { status: "accepted" } }, { type: "callout", x: 6, y: 28, label: "Watcher flagged", dx: 20, dy: -36, lifecycle: { status: "proposed" } }, // → "Watcher flagged (proposed)", dimmed { type: "callout", x: 7, y: 32, label: "Spike contested", dx: 30, dy: -30, lifecycle: { status: "disputed" } }, // → "Spike contested (?)", dimmed { type: "callout", x: 9, y: 35, label: "Withdrawn note", lifecycle: { status: "retracted" } }, // → filtered out ]) } export default () => { return <LineChart {...frameProps} /> }
Accept / dispute / retract / propose transitions are alsoobservable: callrecordAnnotationStatusChange(toStatus, { annotationId, fromStatus })from your review UI and the move lands in theconversation arc — the annotation becomes the durable node the arc is about, not chart chrome.
Anchor modes
lifecycle.anchor controls how a note re-resolves its position as data changes. Mirror it onto the annotation's top-levelanchor field (or let applyAnnotationLifecycledo it) so the streaming resolver picks it up.
| anchor | Behavior |
|---|
| "fixed" | Default. Anchored to fixed data coordinates; disappears when out of view. |
| "latest" | Re-pins to the most recent datum each frame. |
| "sticky" | Stays at its last known pixel position after the target scrolls off. |
| "semantic" | Re-resolves via provenance.stableId when new data arrives, falling back to the recorded coordinate if the target is gone. |
Semantic anchor demo
The callout below is authored once at the recorded coordinate{ x: 5, y: 20 }, but its lifecycle asks Semiotic to resolve by provenance.stableId. When the same target id appears at a new point after refresh, the note moves with that target. When the target id is absent, the note falls back to its recorded coordinate.
Original data:Target present at week 5, score 20
JSX
const annotation = { type: "callout", x: 5, y: 20, label: "Same semantic note", provenance: { stableId: "q3-spike" }, lifecycle: { anchor: "semantic", status: "accepted" }, } <LineChart data={refreshedData} xAccessor="week" yAccessor="score" showPoints pointIdAccessor="stableId" annotations={[annotation]} />
Provenance is invisible, but it travels
Provenance and lifecycle are metadata — the chart below looks like any annotated line chart — but they ride along in the config, survivetoConfig / fromConfig round-trips, and are exactly what an agent or a non-visual reader reads to interpret the note faithfully.
import { LineChart } from "semiotic" const frameProps = { /* --- Data --- */ data: [{ week: 1, score: 12 }, { week: 2, score: 18 }, ... ], /* --- Process --- */ xAccessor: "week", yAccessor: "score", /* --- Customize --- */ xLabel: "Week", yLabel: "Score", /* --- Annotate --- */ annotations: [ { type: "y-threshold", y: 25, label: "Target (set by analytics)", color: "#22c55e", // provenance / lifecycle ride along invisibly: provenance: { authorKind: "agent", basis: "rule", confidence: 0.9 }, lifecycle: { ttlHint: "P30D", status: "accepted" } } ] } export default () => { return <LineChart {...frameProps} /> }
Helpers
withProvenance(annotation, { provenance?, lifecycle? }) — attach the blocks (pure).withCurrentProvenance(annotation, rest?) — stamp createdAt = now unless already set.currentTimestamp() — ISO 8601 now for createdAt.computeAnnotationFreshness(annotations, options?) — populate lifecycle.freshness.annotationFreshnessFor(annotation, nowMs, thresholds?) — classify a single annotation.applyAnnotationLifecycle(annotations, options?) — freshness + default visual treatment.filterAnnotationsByStatus(annotations, options?) — hide retracted and superseded notes without applying styles.applyAnnotationStatus(annotations, options?) — editorial status visibility + default visual treatment.bandFromAge(ageMs, ttlMs, thresholds?) — the shared lifecycle-band primitive.