From 52c96a66aaa130d6556c7ecf73dc2dc42b7e6e9c Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Wed, 2 Sep 2026 19:15:11 -0500 Subject: [PATCH 1/2] AMPR-323 #737: Observed interface + FreshnessProbe with per-Probe max age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `Observed` (one field: `observedAt: Instant`) to the probe package and have `CanonProvenance` implement it — conformance only, no wire change. Ship `FreshnessProbe : Probe`: within `maxAge` → Holds; beyond it → Undetermined(STALE), not Violated (convict-but-not-acquit); `observedAt` ahead of `now` → Warn for clock skew. Max-age lives on the Probe, never on the fact. Tests pin the three outcomes against a fixed clock, run the Probe over a plain `object : Observed` and over a `CanonProvenance` to prove the contravariance path, and show it listed in a `ProbeRegistry`. New `docs/concepts/probe.md` records where `observedAt` binds for each observation kind (Plug perceive, relay fetch, Recall binding) and that max-age is a Probe parameter; `domain-canon.md` gains the matching invariant. Concept-Verified: DomainCanon Co-Authored-By: Claude Fable 5.1 --- .../socket/ampere/canon/CanonProvenance.kt | 12 ++- .../socket/ampere/probe/FreshnessProbe.kt | 45 +++++++++ .../link/socket/ampere/probe/Observed.kt | 23 +++++ .../socket/ampere/probe/FreshnessProbeTest.kt | 98 +++++++++++++++++++ docs/concepts/_index.md | 1 + docs/concepts/domain-canon.md | 5 +- docs/concepts/probe.md | 78 +++++++++++++++ 7 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/Observed.kt create mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt create mode 100644 docs/concepts/probe.md diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonProvenance.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonProvenance.kt index e744cdfb..a02904fb 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonProvenance.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonProvenance.kt @@ -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 @@ -61,6 +62,13 @@ 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` (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. @@ -68,6 +76,6 @@ data class NativePayload( @Serializable data class CanonProvenance( val sourceHandle: SourceHandle, - val observedAt: Instant, + override val observedAt: Instant, val nativePayload: NativePayload? = null, -) +) : Observed diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt new file mode 100644 index 00000000..92bdfa30 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt @@ -0,0 +1,45 @@ +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("ampere.freshness"), +) : Probe { + + 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, + ) + } + } +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/Observed.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/Observed.kt new file mode 100644 index 00000000..b4f10af5 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/Observed.kt @@ -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` 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 +} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt new file mode 100644 index 00000000..685543fd --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt @@ -0,0 +1,98 @@ +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) + assertEquals(UndeterminedCause.STALE, verdict.cause) + } + + @Test + fun `a suite over Observed accepts the freshness probe beside a wider one`() = runTest { + val anyProbe = object : Probe { + override val id = ProbeId("any") + + override suspend fun evaluate(subject: Any): Verdict = Verdict.Holds() + } + val suite = ProbeSuite(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 `registry lists the freshness probe under its default id`() { + val registry = ProbeRegistry() + registry.register(probe) + + assertEquals(listOf>(probe), registry.all()) + assertEquals(probe, registry.get(ProbeId("ampere.freshness"))) + } +} diff --git a/docs/concepts/_index.md b/docs/concepts/_index.md index f00551d3..1578bb73 100644 --- a/docs/concepts/_index.md +++ b/docs/concepts/_index.md @@ -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` — 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. | diff --git a/docs/concepts/domain-canon.md b/docs/concepts/domain-canon.md index ed559833..96197308 100644 --- a/docs/concepts/domain-canon.md +++ b/docs/concepts/domain-canon.md @@ -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 @@ -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` 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. diff --git a/docs/concepts/probe.md b/docs/concepts/probe.md new file mode 100644 index 00000000..485d0f1d --- /dev/null +++ b/docs/concepts/probe.md @@ -0,0 +1,78 @@ +--- +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` 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. + +`FreshnessProbe : Probe` is the first shipped Probe. `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`. +- `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/FreshnessProbe.kt` — per-Probe `maxAge`, injected `now`. +- `commonTest/.../probe/` — `ProbeSuiteTest`, `ProbeSerializationTest`, `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(listOf(freshness, ...)).evaluate(subjectId, subject)`. +- **Expose Probes for listing** — `ProbeRegistry().register(probe)`; `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. From 7bc5c7e12b9b5de20b34c99984fee6d743502020 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Wed, 2 Sep 2026 19:54:12 -0500 Subject: [PATCH 2/2] AMPR-323: register FreshnessProbe in registerAmpereProbes beside SequenceProbe AMPR-322 landed the sample wiring this ticket's task 3 targets. `registerAmpereProbes(freshnessMaxAge, now)` takes the tolerance as a parameter (default 24h) because max-age is consumer policy, not a fact field. `FreshnessProbe.ID` mirrors `SequenceProbe.ID`. Co-Authored-By: Claude Fable 5.1 --- .../link/socket/ampere/probe/AmpereProbes.kt | 18 +++++++++++++++++- .../link/socket/ampere/probe/FreshnessProbe.kt | 6 +++++- .../socket/ampere/probe/FreshnessProbeTest.kt | 9 ++++----- docs/concepts/probe.md | 8 +++++--- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/AmpereProbes.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/AmpereProbes.kt index e456285d..cbff0acd 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/AmpereProbes.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/AmpereProbes.kt @@ -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 diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt index 92bdfa30..682fb793 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/probe/FreshnessProbe.kt @@ -28,9 +28,13 @@ import kotlinx.datetime.Instant class FreshnessProbe( private val maxAge: Duration, private val now: () -> Instant, - override val id: ProbeId = ProbeId("ampere.freshness"), + override val id: ProbeId = ProbeId(ID), ) : Probe { + companion object { + const val ID = "ampere.freshness" + } + override suspend fun evaluate(subject: Observed): Verdict { val age = now() - subject.observedAt return when { diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt index 685543fd..c14467c4 100644 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/probe/FreshnessProbeTest.kt @@ -88,11 +88,10 @@ class FreshnessProbeTest { } @Test - fun `registry lists the freshness probe under its default id`() { - val registry = ProbeRegistry() - registry.register(probe) + fun `ampere wiring lists the freshness probe beside the sequence probe`() { + val registry = ProbeRegistry().registerAmpereProbes(freshnessMaxAge = maxAge, now = { now }) - assertEquals(listOf>(probe), registry.all()) - assertEquals(probe, registry.get(ProbeId("ampere.freshness"))) + assertEquals(listOf(ProbeId(SequenceProbe.ID), ProbeId(FreshnessProbe.ID)), registry.all().map { it.id }) + assertIs(registry.get(ProbeId("ampere.freshness"))) } } diff --git a/docs/concepts/probe.md b/docs/concepts/probe.md index 485d0f1d..365b5679 100644 --- a/docs/concepts/probe.md +++ b/docs/concepts/probe.md @@ -18,7 +18,7 @@ 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. -`FreshnessProbe : Probe` is the first shipped Probe. `Observed` is +`SequenceProbe : Probe` and `FreshnessProbe : Probe` 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. @@ -41,8 +41,10 @@ type would leave foreign subjects with no base to extend. - `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`. -- `commonTest/.../probe/` — `ProbeSuiteTest`, `ProbeSerializationTest`, `FreshnessProbeTest`. +- `probe/AmpereProbes.kt` — `registerAmpereProbes(freshnessMaxAge, now)`, the one-call wiring for a listing. +- `commonTest/.../probe/` — `ProbeSuiteTest`, `ProbeSerializationTest`, `SequenceProbeTest`, `FreshnessProbeTest`. ## Where `observedAt` binds @@ -69,7 +71,7 @@ and is never re-stamped on receipt, cache hit, or Plan. - **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(listOf(freshness, ...)).evaluate(subjectId, subject)`. -- **Expose Probes for listing** — `ProbeRegistry().register(probe)`; `all()` feeds the Oscilloscope listing. +- **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