Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import link.socket.ampere.link.LinkId
import link.socket.ampere.probe.Observed

/** Ampere-scoped identity for a canon entity. Stable across projections. */
@JvmInline
Expand Down Expand Up @@ -61,13 +62,20 @@ data class NativePayload(
* Every canon entity carries one. An entity with no provenance is not a canon
* entity — it is a guess.
*
* Implements [Observed] so a `Probe<Observed>` (e.g.
* [link.socket.ampere.probe.FreshnessProbe]) runs over any canon entity's
* provenance. Conformance only: the field already existed, and the wire shape
* is unchanged.
*
* @property observedAt When the entity was read from its source — the
* framework's clock at perceive. Never re-stamped downstream.
* @property nativePayload Null when the adapter could not or would not carry
* the native object (large binaries, provider policy). A null payload does
* not disable write-back; it forces the adapter to re-fetch before merging.
*/
@Serializable
data class CanonProvenance(
val sourceHandle: SourceHandle,
val observedAt: Instant,
override val observedAt: Instant,
val nativePayload: NativePayload? = null,
)
) : Observed
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
package link.socket.ampere.probe

import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant

/**
* The Probes Ampere itself ships, in one place so a host that builds a
* [ProbeRegistry] for an Oscilloscope listing registers them all with one
* call. Consumers add their own Probes to the same registry afterwards.
*
* [freshnessMaxAge] is the [FreshnessProbe] tolerance. It is a parameter here
* because max-age is consumer policy, never a fact field; the default exists
* only so a listing can be built without choosing one.
*
* Returns the receiver so it composes:
* `ProbeRegistry().registerAmpereProbes().also { it.register(myProbe) }`.
*/
fun ProbeRegistry.registerAmpereProbes(): ProbeRegistry = apply {
fun ProbeRegistry.registerAmpereProbes(
freshnessMaxAge: Duration = DEFAULT_FRESHNESS_MAX_AGE,
now: () -> Instant = Clock.System::now,
): ProbeRegistry = apply {
register(SequenceProbe())
register(FreshnessProbe(maxAge = freshnessMaxAge, now = now))
}

/** Tolerance used by [registerAmpereProbes] when the host does not pass one. */
val DEFAULT_FRESHNESS_MAX_AGE: Duration = 24.hours
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package link.socket.ampere.probe

import kotlin.time.Duration
import kotlinx.datetime.Instant

/**
* Freshness guarantee: is a Recalled fact recent enough to be used as
* evidence? A spec fetched in March is not evidence in June.
*
* Max-age is per-Probe, not per-fact: the fact carries the observation
* ([Observed.observedAt]); the consumer carries the policy ([maxAge]). This is
* the same split the eval SPI makes between a `Reading` and its `Tolerance`.
*
* Outcomes:
* - age within [maxAge] → [Verdict.Holds].
* - age beyond [maxAge] → [Verdict.Undetermined] with
* [UndeterminedCause.STALE], not [Verdict.Violated]. A stale fact does not
* prove the constraint false; it means the evidence cannot be used to
* acquit. Convict-but-not-acquit.
* - `observedAt` ahead of [now] (clock skew) → [Verdict.Warn]. The fact is not
* stale, but a timestamp from the future says one of the two clocks is
* wrong, and the age it implies cannot be trusted.
*
* @property maxAge Oldest observation this Probe will accept as evidence.
* @property now Clock, injected so tests can pin it. Production passes
* `Clock.System::now`.
*/
class FreshnessProbe(
private val maxAge: Duration,
private val now: () -> Instant,
override val id: ProbeId = ProbeId(ID),
) : Probe<Observed> {

companion object {
const val ID = "ampere.freshness"
}

override suspend fun evaluate(subject: Observed): Verdict {
val age = now() - subject.observedAt
return when {
age.isNegative() -> Verdict.Warn(reason = "observedAt is ${-age} ahead of now")
age <= maxAge -> Verdict.Holds(reason = "observed $age ago")
else -> Verdict.Undetermined(
reason = "observed $age ago, max $maxAge",
cause = UndeterminedCause.STALE,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package link.socket.ampere.probe

import kotlinx.datetime.Instant

/**
* Anything that knows when it was last observed from its source.
*
* [link.socket.ampere.canon.CanonProvenance] implements it for canon
* entities. Consumers implement it on their own binding types for
* canon-external observations — a Socket `ManifestLine` copies
* `WebPage.fetchedAt` here at bind time — so a `Probe<Observed>` runs over
* either without Ampere ever learning the concrete type. The Probe's subject
* is the Recall *binding*, not the observation.
*
* Contract 3: [observedAt] is bound at the earliest moment that can know it —
* the relay's clock at upstream-response completion for a fetch, the
* framework's clock at perceive for a Plug — and is never re-stamped on
* receipt, cache hit, or Plan. The tolerance for how old an observation may be
* lives on the consumer's Probe ([FreshnessProbe.maxAge]), never on the fact.
*/
interface Observed {
val observedAt: Instant
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package link.socket.ampere.probe

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import link.socket.ampere.canon.CanonProvenance
import link.socket.ampere.canon.SourceHandle
import link.socket.ampere.link.LinkId

/**
* AMPR-323 task 2 validation: the three outcomes against a pinned clock, and
* the contravariance path — a plain `object : Observed` that is not a canon
* type runs through the same Probe as a [CanonProvenance].
*/
class FreshnessProbeTest {

private val now = Instant.parse("2026-06-01T12:00:00Z")
private val maxAge = 1.hours
private val probe = FreshnessProbe(maxAge = maxAge, now = { now })

/** A consumer-side binding: not a canon type, knows nothing but its timestamp. */
private fun observedAt(instant: Instant): Observed = object : Observed {
override val observedAt = instant
}

@Test
fun `fresh observation holds`() = runTest {
val verdict = probe.evaluate(observedAt(now - 30.minutes))

assertEquals(Verdict.Holds(reason = "observed 30m ago"), verdict)
}

@Test
fun `observation exactly at max age still holds`() = runTest {
val verdict = probe.evaluate(observedAt(now - maxAge))

assertEquals(Verdict.Holds(reason = "observed 1h ago"), verdict)
}

@Test
fun `one second past max age is undetermined with cause STALE not violated`() = runTest {
val verdict = probe.evaluate(observedAt(now - maxAge - 1.seconds))

assertEquals(
Verdict.Undetermined(reason = "observed 1h 0m 1s ago, max 1h", cause = UndeterminedCause.STALE),
verdict,
)
}

@Test
fun `observedAt in the future warns about clock skew`() = runTest {
val verdict = probe.evaluate(observedAt(now + 5.minutes))

assertEquals(Verdict.Warn(reason = "observedAt is 5m ahead of now"), verdict)
}

@Test
fun `canon provenance is an Observed subject`() = runTest {
val provenance = CanonProvenance(
sourceHandle = SourceHandle(linkId = LinkId("link-1"), sourceSystem = "apple.mail", nativeId = "native-1"),
observedAt = now - 2.hours,
)

val verdict = probe.evaluate(provenance)

assertIs<Verdict.Undetermined>(verdict)
assertEquals(UndeterminedCause.STALE, verdict.cause)
}

@Test
fun `a suite over Observed accepts the freshness probe beside a wider one`() = runTest {
val anyProbe = object : Probe<Any> {
override val id = ProbeId("any")

override suspend fun evaluate(subject: Any): Verdict = Verdict.Holds()
}
val suite = ProbeSuite<Observed>(listOf(probe, anyProbe))

val reports = suite.evaluate(subjectId = "line-1", subject = observedAt(now - 1.minutes))

assertEquals(listOf(ProbeId("ampere.freshness"), ProbeId("any")), reports.map { it.probeId })
assertEquals(Verdict.Holds(reason = "observed 1m ago"), reports[0].verdict)
}

@Test
fun `ampere wiring lists the freshness probe beside the sequence probe`() {
val registry = ProbeRegistry().registerAmpereProbes(freshnessMaxAge = maxAge, now = { now })

assertEquals(listOf(ProbeId(SequenceProbe.ID), ProbeId(FreshnessProbe.ID)), registry.all().map { it.id })
assertIs<FreshnessProbe>(registry.get(ProbeId("ampere.freshness")))
}
}
1 change: 1 addition & 0 deletions docs/concepts/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ How the cognitive substrate meets the user, the platform, and the plug ecosystem
| [PlugPermissions](plug-permissions.md) | stable | Deterministic gate that runs *before* any plug tool dispatch. Compares manifest + tool-requested permissions against user grants. |
| [LinkLayer](link-layer.md) | stable | A Plug connects through a Link and powers Arcs. Transport belongs to the Link; Links are directional, shared across Plugs, and resolved at Arc execution time. |
| [DomainCanon](domain-canon.md) | stable | Closed catalogue of provenance-carrying domain types in three rings. The IR Arc logic compiles against. Write-back preserves-and-merges by construction. |
| [Probe](probe.md) | experimental | Predicate over a static artifact with a four-valued `Verdict`. `FreshnessProbe : Probe<Observed>` — max-age is on the Probe, stale is `Undetermined(STALE)`, `observedAt` binds once at the source. |
| [Ampere](ampere.md) | stable | The meta-concept: what makes a framework an AMPERE framework. Glass brain, AniMA agents, electrical metaphor, event-first coordination. |
5 changes: 3 additions & 2 deletions docs/concepts/domain-canon.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ tracked_sources:
- ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/**
- ampere-bindings-apple/src/commonMain/kotlin/link/socket/ampere/bindings/apple/**
- ampere-bindings-android/src/commonMain/kotlin/link/socket/ampere/bindings/android/**
related: [LinkLayer, PlugPermissions, AgentSurface, CognitionTrace]
last_verified: 2026-08-01
related: [Probe, LinkLayer, PlugPermissions, AgentSurface, CognitionTrace]
last_verified: 2026-09-02
---

# Domain Canon
Expand Down Expand Up @@ -90,6 +90,7 @@ vocabulary is a *binding*, declared in an edge module that depends on
- **`CanonWorkItem.dependsOn` is the canon's first work-item→work-item edge, and graph invariants are convicted, never constructed away** (AMPR-322). The field cleared the intersection gate without a ruling because a blocking dependency is provider-native on all three work-item providers: Linear `blockedBy`/`blocks` relations, Jira `issuelinks` of type *Blocks*, GitHub sub-issues / "blocked by". It is a `List<CanonId>` under the same-Link rule below — it resolves only within a graph assembled from one Link — and `CanonWorkItem` itself does not check that the referents exist or that the edges are acyclic. Both are *graph* properties, so they belong to `CanonWorkGraph`, and even there they are not an `init` guard: a graph with a dangling edge or a cycle must be constructible, so it can be recorded and then convicted by `SequenceProbe` (`Violated("dangling dependsOn: a -> ghost")`, `Violated("cycle: a -> b -> c -> a")`). Rejecting it at construction would make the defect unrepresentable and, once serialized, undecodable. The same rule reached the planner side in the same change: `BatchIssueCreator` used to drop a back-edge silently and report `success = true` in an order that violated its own declared dependencies; it now refuses the batch with a `dependencyCycle` error naming the path. Timing invariants (offset non-inversion, recurrence-after-dependency) are deliberately not here — per Socket decision D17 a work item's schedule is a `CanonReminder`, and the Task↔Reminder mapping is held caller-side, so timing checks live there in v1. `CanonMilestone` ordering edges were not admitted. No Linear, Jira, or GitHub binding module exists in this repo yet; when one lands, the provider field above is the mapping it adopts and `dependsOn` must not appear on its lossy list.
- **Bulk content never rides an entity.** Counts and schema are canon; rows and bytes resolve out of band through `CanonAssetRef` and `AssetResolver`. `CanonTable` is the worked example: `columnNames` and `rowCount` on the entity, a preview bounded by `CanonTablePreview.bounded`, and full rows behind `contentRef`. The bound is a write-side factory, not a decode-time `require` — rejecting an oversized value at decode would make an already-recorded trace permanently unreplayable, which is the failure the wire-stability invariant exists to prevent.
- **Every canon entity carries provenance.** An entity with no `SourceHandle` is a guess, not a canon entity.
- **`CanonProvenance` is `Observed`, and `observedAt` binds once at perceive.** The timestamp is the framework's clock when `ReadableCanonAdapter.project` ran, and nothing downstream re-stamps it. How old is too old is not a canon question — it is a `FreshnessProbe` parameter (see [Probe](probe.md)); adding a `maxAge`/`ttl` field to provenance is a violation.
- **Write-back merges; it never replaces.** `WritableCanonAdapter.writeBack` is the only path that touches an *existing* native object, and it always routes through `mergeForWriteBack`, which overlays canon deltas onto the native payload. Adding a write path that bypasses the merge silently destroys every native field the projection dropped.
- **An adapter may only write fields it declares in `ownedFields`.** A `canonFields` result reaching outside that set fails with `UnownedFieldWrite` rather than widening the write footprint. `CreatingCanonAdapter.create` routes through the same guard.
- **`create` touches no existing object, so it cannot clobber — but it is still final and confined to `CreatingCanonAdapter`.** `ReadableCanonAdapter` and `WritableCanonAdapter` gain no create surface; a Plug that only reads or only updates cannot acquire the ability to create by inheritance.
Expand Down
80 changes: 80 additions & 0 deletions docs/concepts/probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
concept: Probe
status: experimental
tracked_sources:
- ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/**
related: [DomainCanon, MemoryProvenance, PropelLoop]
last_verified: 2026-09-02
---

# Probe

## What it is

A `Probe<in S>` is a predicate over one static artifact — a plan graph, a
manifest, a recalled fact — that returns a four-valued `Verdict`: `Holds`,
`Warn` (decided, bad, not disqualifying), `Violated` (decided, disqualifying),
or `Undetermined` (not decided; carries an `UndeterminedCause`). A
`ProbeSuite` runs an ordered list over one subject and yields `ProbeReport`s;
a `ProbeRegistry` lists Probes for discovery (Oscilloscope), not dispatch.

`SequenceProbe : Probe<CanonWorkGraph>` and `FreshnessProbe : Probe<Observed>` are the shipped Probes. `Observed` is
the one-field interface (`observedAt: Instant`) that lets it run over a canon
entity's `CanonProvenance` or a consumer's own binding type without Ampere
importing either.

## Why it exists

Recalled facts go stale — a spec fetched in March is not evidence in June —
and the check is generic to every consumer, so it descends to Ampere (Socket
decision D12). The design pressure came from Blueprint's web observations
being canon-external: `WebPage.fetchedAt` is a Socket type Ampere cannot read.
The resolution is that **the Probe's subject is the Recall binding, not the
observation**: Socket copies `fetchedAt` onto its `ManifestLine` at bind time,
and `Observed` is the name Ampere gives to what that binding implements.
Contravariance in `S` is the whole mechanism; constraining `S` to an Ampere
type would leave foreign subjects with no base to extend.

## Where it lives

- `probe/Probe.kt` — the SPI, `Probe<in S>`.
- `probe/Verdict.kt` — `Verdict` and `UndeterminedCause` (`EVIDENCE_ABSENT`, `EVIDENCE_UNREADABLE`, `STALE`).
- `probe/ProbeSuite.kt`, `probe/ProbeReport.kt`, `probe/ProbeRegistry.kt`, `probe/ProbeId.kt`.
- `probe/Observed.kt` — the timestamp interface; `canon/CanonProvenance.kt` implements it.
- `probe/SequenceProbe.kt` — dangling `dependsOn` and cycles over a `CanonWorkGraph`.
- `probe/FreshnessProbe.kt` — per-Probe `maxAge`, injected `now`.
- `probe/AmpereProbes.kt` — `registerAmpereProbes(freshnessMaxAge, now)`, the one-call wiring for a listing.
- `commonTest/.../probe/` — `ProbeSuiteTest`, `ProbeSerializationTest`, `SequenceProbeTest`, `FreshnessProbeTest`.

## Where `observedAt` binds

Contract 3: the timestamp is bound at the *earliest* moment that can know it,
and is never re-stamped on receipt, cache hit, or Plan.

| Observation kind | Who stamps `observedAt` | Where |
|------------------|-------------------------|-------|
| Plug perceive (canon entity) | The framework's clock at perceive | `ReadableCanonAdapter.project(payload, handle, observedAt)` writes it into `CanonProvenance`; child entities inherit the parent's value via `provenance.forChild` because they were observed in the same read |
| Relay fetch (canon-external, e.g. `WebPage`) | The relay's clock at upstream-response completion | Socket-side; Ampere never sees the page type |
| Recall binding | Nobody new — the binding *copies* the observation's timestamp | Consumer binding type implements `Observed`; a Socket `ManifestLine` copies `WebPage.fetchedAt` at bind time |

## Invariants

- **Max-age is a Probe parameter, never a fact field.** The fact carries the observation (`observedAt`); the consumer carries the policy (`FreshnessProbe.maxAge`). Same split as `Tolerance` on an eval case versus a `Reading`. Adding a `maxAge`/`ttl` to `CanonProvenance` or any `Observed` implementation is a violation.
- **Stale is `Undetermined(STALE)`, not `Violated`.** A stale fact does not prove the constraint false; it means the evidence cannot acquit. Convict-but-not-acquit.
- **`Undetermined` never renders as a soft pass.** A consumer that maps it to "ok" has silently accepted absent evidence.
- **`observedAt` is bound once, at the source.** Re-stamping on cache hit or Plan would make every cached fact look fresh forever.
- **`S` stays unconstrained.** A Probe must be able to check a subject type Ampere never imports.
- **A Probe is not a trace grader.** Grading a recorded run belongs to the eval harness (`EvalCase`, `Meter`s). A freshness verdict cannot be computed from a `MemoryEvent.KnowledgeRecalled` trace alone — it carries no per-fact timestamps — and that is an observability gap, not a reason to move the Probe.

## Common operations

- **Check a canon entity's freshness** — `FreshnessProbe(maxAge = 24.hours, now = Clock.System::now).evaluate(entity.provenance)`.
- **Check a consumer-side binding** — implement `Observed` on the binding, copying the source timestamp at bind time; the same Probe instance applies.
- **Run several Probes over one subject** — `ProbeSuite<Observed>(listOf(freshness, ...)).evaluate(subjectId, subject)`.
- **Expose Probes for listing** — `ProbeRegistry().registerAmpereProbes(freshnessMaxAge = 24.hours)` registers both shipped Probes; add consumer Probes with `register` afterwards. `all()` feeds the Oscilloscope listing.

## Anti-patterns

- **A `Boolean` verdict.** Two independent recons each needed a third value, and different thirds.
- **Reading `WebPage.fetchedAt` from Ampere.** It is a Socket type; the isolation check forbids it and the binding already carries the timestamp.
- **Per-fact tolerances.** Out of scope by design (AMPR-323); a fact does not know how stale is too stale for a given consumer.
Loading