Skip to content

Ambient state snapshots, build-attributed sessions, and span tooling that can't lie - #152

Merged
kyleve merged 26 commits into
mainfrom
cursor/periscope-ambient-snapshots-3fed
Jul 30, 2026
Merged

Ambient state snapshots, build-attributed sessions, and span tooling that can't lie#152
kyleve merged 26 commits into
mainfrom
cursor/periscope-ambient-snapshots-3fed

Conversation

@kyleve

@kyleve kyleve commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Three related things, all in service of being able to read a log weeks later and know what it meant: the system state behind every event, the build behind every session, and span surfaces that don't render claims their data can't support.

Closes nine backlog entries — the ambient-snapshot P0 in Shared/Periscope/TODOs.md, the LogSession build-naming P2 in the root TODOs.md, and seven span-tooling / docs quick wins.

Ambient state snapshots

Every event already referenced one SDLogSession row for "which build, on which device". It now also references one SDAmbientSnapshot row for "what was the system doing" — network, thermal state, power mode, lifecycle — so correlating an error with connectivity stops being a timestamp hunt.

  • Ambient values are JSON objects, not sentences. AmbientEvent.value is [String: AmbientValue] — named fields carried as a plain JSON object through the payload, the snapshot, the store, and the export. AmbientValue is a bare JSON scalar (string/int/double/bool) with a single-value Codable (the documented hand-written exception), so a payload reads as {"status": "satisfied", "expensive": false} rather than a case-keyed enum wrapper. Sources report typed fields: network {status, interfaces}, thermal {level}, power {low-power: Bool}, lifecycle {phase}, memory {pressure}, accessibility one named Bool per setting. Messages render mechanically as sorted key=value pairs.
  • AmbientEvent.reporting (.state / .occurrence) draws the line between a lasting condition and a passing moment. A memory warning is the one built-in .occurrence: the app is not "in a memory warning" afterwards, so it must not stick to every later record.
  • AmbientSnapshot is the latest value of every stateful kind, and its identity is the dedupe key — applying(_:) returns self whenever nothing actually moved, so a run of records sharing one state shares one stored row. folding(_:into:) makes an empty snapshot unrepresentable: a row describing the system while carrying no values would be worse than an honest nil.
  • The pipeline folds and stamps in the existing in-lock buffer path, which now returns the record as buffered so the journal and the sinks get the stamped copy. Folding happens before stamping, so an ambient event carries the state it announces rather than the one it replaced.
  • Folding outlives the admission gates. An ambient event the level floors discard still folds into the running snapshot (floors route, they don't scrub); one that redaction suppresses clears its kind instead — folding it would smear the suppressed value onto every later record, keeping the old one would lie. The snapshot never goes stale because an event was kept out of the record stream.
  • The crash journal carries it, so records that only exist because of a crash still say what the system was doing.
  • Storage is one row per distinct state plus an indexed ambientSnapshotID, and retention takes unreferenced snapshots with it.
  • Thermal state and low-power mode report at started(), so their state isn't unknown until it next changes.
  • Accessibility toggles re-report the full summary. The value folds into the snapshot under one kind, so a single-setting delta would replace the complete accessibility state every later record is stamped with.

A session can name the build it came from

LogSession.current() read only CFBundleShortVersionString / CFBundleVersion, and the Where app pins both in the manifest — so every developer build read v1.0 (1) and weeks-old logs couldn't be tied to the code that produced them.

LogSession.attributes ([LogSessionAttributeKey: String]) is the seam: the host app fills it at bootstrap, keeping Periscope below the Where modules rather than reaching up for a build stamp. Where's stamp-build-info.sh now writes WhereConfiguration / WhereSwiftOptimizationLevel / WhereSwiftCompilationMode beside the commit keys, BuildInfo reads them back, and the viewer's session picker shows commit + optimization level. The stamp script also stops trusting a failed git status: exit code and output are separate signals, so a build stamps unknown — never clean — when the status check itself fails.

The optimization level is the load-bearing field, not the commit: a span duration from an -Onone build says nothing about the shipping app, and the configuration alone can't answer it (a Debug configuration can be compiled -O). So SpanHistoryView also gained a build scope — all builds, this session, or every session built at the current one's level — and labels the active scope, because a p95 that silently pools an unoptimized build with an optimized one measures nothing.

The NDJSON export carries the attribution too: it opens with one "record": "session" line per session the exported events reference — identity, device/OS metadata, and the build attributes — so a duration in a bug report names the build it came from instead of a bare session UUID. Ambient state exports as nested JSON objects with native scalar types.

Span tooling that can't lie

  • SpanNode carried parallel ended / exitMode / duration optionals, so a span whose end payload failed to decode rendered an exit chip beside a "running" duration — a reading that cannot be true, since the exit comes from an indexed column and the duration from the payload. One Outcome replaces them, and its Timing distinguishes an end that measured nothing (an orphan) from one whose payload wouldn't decode.
  • Span history labels a recovered bucket as recovered — and recovers the name, not the message. SpanEnded.nameRecovered(fromMessage:exit:) (the inverse of the message format, living beside it in Core) strips the exit word, reason, and duration, so undecodable rows of one kind share one bucket instead of fragmenting into one per row.
  • The scope summary counts sessions contributing ends — a session that never recorded a span doesn't pad the count — and the empty state gets its own model-provided line instead of a lowercased summary (which garbled -Onone).
  • Orphan-sweep ends are attributed to the session that began them. The synthetic .orphaned end closes the previous launch's work; landing it in the sweeping session polluted "this session" readings with spans the new launch never ran. The sweep decides from the persisted spanRelaunchPolicy column alone; a corrupt payload only costs the synthetic end its recorded name.
  • Event detail says "unreadable payload (N bytes)" when payload bytes exist but don't parse, instead of hiding the section as if none was recorded.
  • Every read model logs its failures to PeriscopeToolsLog.failures (including NDJSON export) — deliberately OSLog, not Periscope: these surfaces reload on every store commit, so logging into the store they read would turn one corrupt row into a refresh loop.
  • SpanTreeRow reads \.logRowDensity instead of hard-coding comfortable, so the viewer's density picker actually reaches span-tree rows.

Notable decisions

  • Pre-release means no migration tolerance. Per review, the store is deleted rather than migrated across schema changes, so every Codable conformance stays synthesized (no hand-written decoders defaulting missing keys, no eventVersion bumps for decode boundaries, no optional-for-migration columns, no committed pre-upgrade fixture). PeriscopeCore/AGENTS.md records the posture.
  • Two ambient stamping sites, not one. The drop report is synthesized during the drain and never passes through the buffer path, so it is stamped where it is constructed — otherwise the one record that marks a gap in the history couldn't say what the system was doing when the gap opened.
  • No app-lifecycle startup baseline. Emitting one means reading UIApplication.shared.applicationState at launch, which reads .background even for a user tap under the UIScene lifecycle — the trap LifecycleReason.undetermined exists to avoid. A baseline there would stamp a confidently wrong value on every early record; the first real transition fills it correctly a moment later. A test pins the omission so it reads as deliberate.
  • The startup read-vs-fold race is accepted, and documented. Notification sources register observers before the baseline read, both sides read current state at emit time, and the next real transition corrects any skew — ordering machinery would buy nothing measurable. Written down on NotificationAmbientSource.started().
  • An unstamped bundle claims nothing. BuildInfo.logSessionAttributes is empty rather than reporting the stamp script's unknown placeholder as a configuration — a session that can't name its build should read as unidentified, not as a build called "unknown".
  • degradeSpanBegan is a test seam for degraded on-disk shapes. Nothing a test can write produces a payload that won't decode, so the sweep's degraded-name path was unreachable from a test without it.

Testing

tuist test Stuff-iOS-Tests passes in full (1469 tests, the multi-bundle scheme). Coverage spans the snapshot value type (including the plain-JSON-object encoding), pipeline stamping (spans, the drop report, live observers, gate-discarded ambient events), journal round-trip and crash recovery, store dedupe / reads / orphan pruning and attribution, session attribute round-tripping, the build-scope filter and its contributing-session summary, the span outcome model under unreadable payloads, and recovered-name grouping.

kyleve added 11 commits July 28, 2026 15:02
Closes the first step of the ambient-state-snapshot P0: the snapshot needs
to know which events describe a lasting condition (fold them in) and which
describe an instant (don't). `AmbientEvent.reporting` draws that line, and
a memory warning becomes the one built-in `.occurrence` — the app is not
"in a memory warning" afterwards, so it must not stick to every later
record.

`eventVersion` goes to 2. Decoding is hand-written for one load-bearing
reason: v1 rows have no `reporting` key at all and synthesized decoding
throws on a missing key rather than defaulting. Every v1 ambient event was
a state change, so absence decodes as `.state`, covered by a test that
feeds the decoder the v1 shape.
Pure value-type groundwork for the P0: no pipeline is wired to it yet.

A snapshot is the latest value of every stateful ambient kind, and its
identity is the dedupe key — `applying(_:)` returns `self` whenever nothing
actually moved, so a run of records sharing one system state will share one
stored row instead of one row per record.

`folding(_:into:)` handles the "nothing observed yet" case so an empty
snapshot is unrepresentable: a row claiming to describe the system while
carrying no values would be worse than an honest absence.

`AmbientKind: CodingKeyRepresentable` keeps the values dictionary encoding
as a JSON object keyed by kind rather than a flat alternating array. It is
deliberately not `RawRepresentable`, which could change how a kind encodes
inside an `AmbientEvent` payload and invalidate stored rows.
The point of the P0: any event — not just the ambient ones — can now be
joined to what the system was doing when it happened, the same way it is
already joined to its session.

Folding and stamping both happen inside the existing in-lock buffer path,
which returns the record *as buffered* so the journal and the sinks receive
the stamped copy rather than the pre-stamp original. Folding runs before
stamping, so an ambient event carries the state it announces instead of the
one it replaced.

The drop report needs its own stamp: it is synthesized during the drain and
never passes through the buffer path, so it would otherwise be the one
record that couldn't say what the system was doing when the gap opened.
A record whose only copy is the journal should still say what the system
was doing when it was emitted, so `LogJournalRecord` carries the stamped
snapshot and an end-to-end test asserts it survives emit → disk → recover.

The field is `Optional` on purpose, and a test pins that: journals are
written before an upgrade and ingested after one, so an entry from a build
that predates ambient state has no `ambient` key at all. Optional means
synthesized decoding tolerates that instead of throwing away the journal.

Ingest maps the snapshot onto its row in the next commit, with the column
to put it in.
Completes the durable half of the P0. Events reference their ambient state
the way they already reference their session: a `SDAmbientSnapshot` row per
distinct state plus an indexed `ambientSnapshotID` on the event, so a run of
events that shared one system state costs one row rather than one copy each.
Snapshot values are a dictionary attribute rather than a JSON blob, so
reading a snapshot back has no decode step and therefore no failure mode to
swallow. Journal ingest maps recovered records onto their rows too.

Retention takes unreferenced snapshots with it — one row per distinct state
means they otherwise accumulate for as long as the app keeps changing
network, thermal, or power state.

Both new columns are optional so SwiftData can infer the migration, and that
is verified rather than assumed: a store written by the previous commit's
schema (from a worktree at that commit) opens under the new one, its rows
read back with ambient state honestly absent, and subsequent writes with
snapshots commit with zero write failures.

`LogRecord.stamped(ambient:)` is a DEBUG `@_spi(Testing)` seam: store tests
hand records to `write(_:)` directly, with dates they choose, so they can't
obtain a stamped record from a live pipeline. One test does go end to end
through a real pipeline, and another through crash-journal recovery.
Without a baseline the snapshot has nothing to say about either until the
first transition — and a device that launches hot and stays hot, or a session
that runs entirely in Low Power Mode, never posts one at all. Both read
nonisolated `ProcessInfo` state, so `started()` needs no actor hop, matching
how the accessibility source already emits its summary.

The app-lifecycle source deliberately gets no baseline, and a test now pins
that: reading `UIApplication.applicationState` at launch reports `.background`
even for a user tap under the UIScene lifecycle — the trap
`LifecycleReason.undetermined` exists to avoid — so a baseline would stamp a
confidently wrong value onto every early record of a normal launch. The first
real transition fills it in honestly.
Stamping state onto records only pays off if a developer can see it. The
event detail view resolves the snapshot on demand — one row serves many
events, so queries don't join it per row — and the export resolves the whole
set once and embeds each event's state as a JSON object.

A referenced snapshot that isn't found is reported rather than omitted, in
both surfaces: retention only drops *unreferenced* snapshots, so a missing
one is a real inconsistency and must not read as "nothing was known about
the system".
The version and build number can't identify a build when the manifest
pins both, so every developer build's sessions read `v1.0 (1)` and
week-old events can't be tied to the code that produced them. A recorded
duration has the same problem from the other side: without the
optimization level there's no telling a measurement that says something
about the shipping app from one taken off an `-Onone` build.

`LogSession` grows an `attributes` dictionary keyed by a typed
`LogSessionAttributeKey`, with well-known keys for the commit, its
clean/dirty status, the configuration, the optimization level, and the
compilation mode. PeriscopeCore can't reach WhereCore, so it's a seam the
host app fills rather than something Periscope reads: the stamp script
now writes `CONFIGURATION`, `SWIFT_OPTIMIZATION_LEVEL`, and
`SWIFT_COMPILATION_MODE` into the app's Info.plist beside the commit
keys, `BuildInfo` reads them back as a `Compilation` value, and
`bootstrapLogging` passes `BuildInfo.logSessionAttributes` to
`LogSession.current`. The viewer's session picker names the commit and
optimization level instead of the pinned version pair.

An unstamped bundle claims nothing rather than claiming it was built from
a commit named `unknown`, so a session that can't identify its build
reads as unidentified. `LogSession` decodes a payload without
`attributes` as an empty set — the crash journal ingests entries written
by earlier app versions — and `SDLogSession.attributes` is optional so
existing rows take SwiftData's lightweight migration.

Closes the "a LogSession can't name the build it came from" P2 in the
root TODOs.
A p99 pooled half from an unoptimized developer build and half from an
optimized one describes neither, and nothing in the number tells the
reader it happened. Now that a session names its optimization level, the
history can separate them.

`SpanHistoryView` gains a build scope — all builds, this session, or every
session built at the same optimization level — resolved against the
sessions the store actually recorded, so a store whose sessions never
named a level doesn't offer to group by one. The list header names what
the percentiles cover, and the empty state distinguishes "nothing
recorded" from "nothing in this scope".

Re-scoping filters the ends already accumulated instead of refetching, so
narrowing stays as live and as cheap as the unscoped reading. `all` stays
the default: a reader who hasn't chosen sees every run, and narrowing is
an explicit act. An unresolvable scope admits no sessions rather than
widening back to every build, so a label can't say one thing while the
data says another.

`PeriscopeStore.currentSession` is the new seam this reads — the session
writes are attributed to, `nil` before one exists rather than
synthesizing one a later write would adopt.
Closes the "span-tooling quick wins" plan step.

Three things the span surfaces got wrong, all of them silent:

- Every read model swallowed its failures. A store read that threw, or a
  payload that wouldn't decode, set `.failed` (or quietly grouped a row by
  its message) with nothing written anywhere a developer could see it.
  They now report to `PeriscopeToolsLog.failures`, an OSLog channel rather
  than Periscope itself: these surfaces reload on every store commit, so
  logging into the store they read would turn one corrupt row into a
  refresh loop.

- `SpanNode` carried parallel `ended` / `exitMode` / `duration` optionals,
  so a span whose end payload failed to decode rendered an exit chip
  beside a "running" duration — a reading that cannot be true, since the
  exit comes from an indexed column and the duration from the payload.
  One `Outcome` replaces them, and its `Timing` tells an end that
  measured nothing apart from one whose payload wouldn't decode.

- `SpanTreeRow` read `stylesheet.row.comfortable` directly, ignoring the
  density it was handed.

The orphan sweep also stops parsing payloads to decide relaunches:
`SpanRelaunchPolicy` is persisted as a column on the began row, and the
sweep filters survivors from it. A row written before the column falls
back to its payload, and a payload that can't be read can't prove the
span asked to survive — so it's closed, with the failure logged rather
than silently deciding against the policy. `degradeSpanBegan` is the test
seam for that pair of degraded shapes; nothing a test can write produces
them.
Closes the "PeriscopeCore/PeriscopeTools docs" plan step.

The invariants an agent can't re-derive from the code: that an ambient
event declares whether it's a state or an occurrence (and why folding an
occurrence in would make every later record claim the app was mid-memory-
warning); that a snapshot's identity only changes when a value moves,
which is what makes "one row per distinct state" true; that a session's
build attributes come from the app because Periscope sits below it; that
the relaunch sweep reads a column and logs when it can't; that the tools
log their own failures to OSLog rather than into the store they read; and
that a timing reading has to name the builds it pools.

Also fixes a doc claim that was already wrong: the crash-durability
section read as though the journal covered the whole process lifetime. It
doesn't — it opens with the store, and `PeriscopeStore.make` is `async`.
That was its own backlog item.

Closes eight backlog entries across the Periscope and root TODOs, and
adds the two missing `*+Display` test files that another one asked for.
@cursor cursor Bot changed the title Ambient state snapshots: join any event to what the system was doing Ambient state snapshots, build-attributed sessions, and span tooling that can't lie Jul 28, 2026
kyleve and others added 10 commits July 28, 2026 18:24
Preserve build-attributed Periscope sessions while adopting scoped log-store routing and demo mode from main.

Co-authored-by: Cursor <cursoragent@cursor.com>
An ambient .state event that the level floors discarded, or that the
redaction hook suppressed, never reached the buffer path - so it never
folded into the running AmbientSnapshot, and every later record was
stamped with the state the discarded event had replaced.

Floors are routing, not scrubbing: a floored ambient event now still
folds, it just isn't recorded. Redaction suppression is content
scrubbing, so a suppressed event clears its kind from the snapshot
instead (new AmbientSnapshot.removing(_:)) - folding the value in would
smear exactly what the hook suppressed across every later record, and
keeping the previous value would lie.
The baseline event carried the complete summary ("enabled: voiceover,
reduce-motion") but each change event carried a single-setting delta
("voiceover: on"). Both fold into the ambient snapshot under the one
.accessibility kind, so the first toggle replaced the complete state on
every subsequently stamped record with whichever setting last moved.

Change events now re-read and emit the same full summary the baseline
does. Also writes down the accepted read-vs-fold startup race on
NotificationAmbientSource.started(): observers register before the
baseline read, both sides read current state at emit time, and the next
real transition corrects any skew.
The sweep persisted its synthetic .orphaned ends through the normal
write path, which attributes rows to the active session - the one whose
startSession is running the sweep. Every orphaned span from the
previous launch therefore landed in the new session, polluting "this
session" span-history scopes and session-filtered viewer queries with
work the new launch never did.

persist() now takes an explicit session attribution, and the sweep
groups its ends by each began row's session so the close lands where
the work happened.
Event lines carried only the session UUID, so an exported bug report
dropped exactly the build attribution this branch adds to sessions -
commit, configuration, optimization level. A duration in an export was
unanswerable without the store it came from.

The export now opens with one "record": "session" line per session the
events reference (oldest first), carrying the session's identity,
device/OS metadata, and its attributes; readers join event lines to
them by the session UUID. The export button also logs its failures to
PeriscopeToolsLog.failures instead of only flipping the alert flag.
A failed `git status --porcelain` (index lock, permissions) prints
nothing, and the stamp script read empty output as a clean tree - a
build could claim `clean` on no evidence. The exit code and the output
are now separate signals: failure stamps `unknown`, like a checkout
with no git metadata at all.
Three fixes to the Span History surface:

- Undecodable ends stopped fragmenting: the recovered bucket name now
  comes from SpanEnded.nameRecovered(fromMessage:exit:) - the inverse
  of the message format, living beside it in Core - which strips the
  exit word, reason, and duration. Grouping by the raw message minted a
  bucket per row, since the duration varies per instance.

- The scope summary counts sessions *contributing* ends, as its doc
  always claimed, instead of every session the scope would admit - a
  session that never recorded a span no longer pads the count.

- The empty state gets its own model-provided line instead of
  lowercasing the summary, which garbled case-sensitive values
  ("built at -onone").
The v1 fallback decoded every reporting-less row as .state, but v1's
only .memory source was the memory-warning source - and a warning
describes an instant, not a condition the app stayed in. Old memory
rows read back through tooling now say .occurrence, matching what they
meant when they were written.
prettyPayload returned nil for both "no payload recorded" and "bytes
exist but don't parse", so a corrupt payload hid the Payload section
entirely - reading as if the event never carried one. The detail view
now presents a PayloadPresentation: pretty JSON when it parses, an
explicit unreadable marker (with the byte count) when it doesn't, and
no section only when nothing was recorded.
The branch adds a model (SDAmbientSnapshot), three optional columns
(spanRelaunchPolicy, session attributes, ambientSnapshotID), and an
index - all shapes SwiftData should migrate lightweight, but nothing
exercised that against a database an old build actually wrote.

Commits Tests/Fixtures/PreAmbientSchema.store, generated by an
origin/main build of PeriscopeCore (macOS worktree, scratch executable,
WAL checkpointed so the single file is self-contained), and a suite
that copies it to a scratch directory and opens it with the current
schema: old rows and sessions read back (attributes empty), the orphan
sweep closes the old launch's open span from its payload and attributes
the end to the old session, v1 ambient rows decode, and new writes
exercise the snapshot table. The unitTests Project.swift helper gains a
resources parameter to carry the fixture into the test bundle.
/// diagnose what the system was doing around an error.
public struct AmbientEvent: LogEvent, Hashable {
public static let eventName = "ambient"
/// v2 added ``reporting``; v1 rows decode as ``Reporting/state``.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't worry about data migration actually; since the app is pre-release we can just delete the old DB and change the model

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Done in 9a2145c — the migration tolerance is gone wholesale: the hand-written decoders (here and on LogSession), the eventVersion bump, the optional-for-migration SDLogSession.attributes column (now non-optional), the orphan sweep's pre-column payload fallback, and the fixture + upgrade tests. PeriscopeCore/AGENTS.md now records the pre-release posture: synthesized Codable, no decode tolerance, delete the store on shape changes.

}

public var kind: AmbientKind
public var value: String

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not new but it would be nice if this was a JSON object instead.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Done in 139b37cvalue is now [String: AmbientValue], a plain JSON object end to end (payload, snapshot, store, NDJSON export). AmbientValue is a bare JSON scalar (string/int/double/bool) with a single-value Codable, so a payload reads as {"status": "satisfied", "expensive": false} rather than a case-keyed enum wrapper. Sources now report typed fields — accessibility is one named Bool per setting, power mode a real boolean — and messages render mechanically as sorted key=value pairs.

case reporting
}

public init(from decoder: any Decoder) throws {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...Once we just change the schema we can remove this manual implementation.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Done in 9a2145c — the hand-written init(from:) is gone and the conformance is fully synthesized again (the eventVersion = 2 bump went with it).

/// `CodingKeyRepresentable` so dictionaries keyed by it encode as JSON
/// objects instead of the flat alternating key/value array a dictionary with
/// non-string keys falls back to.
struct StringCodingKey: CodingKey {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is useful. It's OK to have a nested struct. Let's remove this.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Done in 335ac36StringCodingKey is gone; AmbientKind and LogSessionAttributeKey each carry a private nested Key struct in their CodingKeyRepresentable conformances.

/// and synthesized decoding throws on the missing key rather than defaulting.
/// An older build simply couldn't name itself, which is an empty set of
/// attributes — not a corrupt session. `encode(to:)` stays synthesized.
extension LogSession {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per above, let's just remove this. I'll delete the DB to avoid a migration.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Done in 9a2145c — the hand-written init(from:) is removed; LogSession decodes synthesized. (Journals live beside the database, so deleting the store directory covers the old-journal case this existed for.)

predicate: #Predicate { $0.ambientSnapshotID == snapshotID },
)
events.fetchLimit = 1
if try modelContext.fetchCount(events) == 0 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add some sort of isEmpty fetch that sets its limit to 1 to short circuit this vs getting the whole count?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Good instinct, with a twist: fetchCount already honors fetchLimit — that's why the limit was set — so the code never tallied the whole table. But the idiom reads as "count everything" unless you know that, which is the real problem. 5cb0afb names it: both pruning loops now go through hasNoEvents(matching:), whose doc comment states the limit-honoring fact.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets just delete this per other comments.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Deleted in 9a2145c, along with the schema-upgrade suite and the Project.swift test-resources plumbing that carried it into the bundle.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll be able to remove this per the above.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Removed in 9a2145c with the fixture it opened.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we remove StringCodingKey we can remove this.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Removed in 335ac36 with StringCodingKey itself; the object-not-array encoding stays covered by the AmbientSnapshot and LogSession encoding tests.

kyleve added 5 commits July 29, 2026 17:42
…grated

Per PR review: the app is pre-release, so old databases and journals are
deleted rather than carried across schema changes. Everything that
existed only to read the pre-ambient-snapshot shape goes:

- AmbientEvent returns to a fully synthesized Codable (no hand-written
  init(from:) defaulting a missing `reporting`, no eventVersion bump).
- LogSession returns to synthesized decoding (no missing-`attributes`
  fallback).
- SDLogSession.attributes becomes non-optional - it was optional only
  for lightweight migration.
- The orphan sweep decides relaunch policy from the column alone; the
  pre-column payload fallback and the degradeSpanBegan seam's
  clearingRelaunchPolicyColumn knob are gone. A corrupt payload now only
  costs the synthetic end its recorded name (new column-alone test).
- The committed PreAmbientSchema.store fixture, the schema-upgrade
  suite, and the test-target resources plumbing that carried it are
  deleted.

PeriscopeCore/AGENTS.md now records the pre-release posture: keep
Codable conformances synthesized; no decode tolerance for older rows.
Per PR review: the shared utility wasn't carrying its weight. Each
CodingKeyRepresentable conformance (AmbientKind, LogSessionAttributeKey)
now carries its own private nested Key struct, and the standalone file
and its tests are gone. The object-not-array encoding behavior stays
covered by the existing AmbientSnapshot and LogSession encoding tests.
Per PR review: the metadata-pruning loops set fetchLimit = 1 before
fetchCount precisely so emptiness checks never tally a whole table -
fetchCount honors the limit - but the idiom reads as "count everything"
unless you know that. One named helper makes the short-circuit legible
and keeps both call sites from drifting apart.
Per PR review: AmbientEvent.value was a String, so every source rendered
its state into a sentence ("enabled: voiceover, reduce-motion") that
tooling could only display or parse back apart. The value is now
[String: AmbientValue] - named fields carried as a plain JSON object all
the way through the payload, the snapshot, the store, and the export.

AmbientValue is a bare JSON scalar (string/int/double/bool) with a
hand-written single-value Codable - the documented exception - so a
stored payload reads as {"status": "satisfied", "expensive": false}
rather than a case-keyed enum wrapper. Messages render mechanically as
sorted key=value pairs, and .ambientDescription shares that rendering
with the viewer's detail section.

The sources now report typed fields: network {status, interfaces},
thermal {level}, power {low-power: Bool}, lifecycle {phase}, memory
{pressure}, and accessibility one named Bool per setting - the full
picture the per-toggle re-report was already carrying, minus the prose.
The network source's change-only filter compares values instead of
description strings. NDJSON exports snapshots as nested JSON objects
with native scalar types.

Pre-release, so the store's rows change shape without migration
(SDAmbientSnapshot.values nests the fields); the DB gets deleted.
@kyleve
kyleve merged commit a1f8ea8 into main Jul 30, 2026
4 checks passed
kyleve added a commit that referenced this pull request Jul 30, 2026
Main restructured the launch under this branch: the store now opens
lazily behind the onboarding gate (demo mode, #150), the durable log
sink became a scope's concern (WhereScope), and #152/#153/#155 landed.
The resolution keeps main's architecture and re-layers this branch's
instrumentation onto it:

- The plan keeps main's shape (gate -> resolve-scope -> start-session)
  with this branch's .measured() composition on every step. The
  ResolveScopeStep inherits the old open-store step's one-second budget
  (it is the store open now); ExitDemoStep gains a budget and joins its
  plan measured; the onboarding gate stays unmeasured (it parks on the
  user).
- bootstrapLogging is gone with its architecture: main's
  startAmbientLogging stays, and the openLogStore span, the two-axis
  LogHistoryPruner, and the pruneHistory span move into WhereScope's
  store bring-up - which main had grown a simpler (olderThan-only)
  version of. The richer historyPruned(expired:overflowed:) event shape
  wins.
- LaunchStepID.openStore no longer exists; MeasuredStepTests and the
  WhereLaunchLog doc example re-key on resolveScope.
- Where/AGENTS.md keeps both sides' new sections (Scopes and the
  launch + Spans); the TODOs size-cap item main re-added drops in
  favor of this branch's completed entry.
cursor Bot pushed a commit that referenced this pull request Aug 9, 2026
All 19 open Periscope items and both JournalKit items checked against HEAD.
None have shipped: the three durability gaps (survivesRelaunch resume, the
pre-store-attach window, multi-process journal coordination) remain the oldest
open work in the repo, now with current citations.

Corrected a wrong premise in the span-record-modeling P0. It claimed spanID and
spanExit are 'bolted onto every LogRecord'; they are computed downcasts on the
record's event (LogSpan.swift:210, :216, :224), and LogRecord stores exactly one
span-related field, bypassesFloors. The denormalized columns live on the store
and journal shapes deliberately, so the orphan sweep reads an indexed value
instead of decoding payloads. Left as a dated correction inside the item,
because it changes what the item is asking for.

Re-counted the hosting-smoke-test debt: 20 assertions across 10 files, not the
18 across 9 filed -- PR #152 added LogEventDetailViewHostingTests, so the
conversion surface grew. PeriscopeTools/SnapshotTests still holds only
PeriscopeViewerSnapshotTests, so none of the conversion has happened.

Refreshed drifted citations throughout (PeriscopeStore.make :84 -> :118,
add(sink:) :192 -> :223, the relaunch warn :577 -> :600, the four density
sites, both rebuild-per-ping models, the inspector's uncursored requery).

Confirmed clean, so not filed: Broadway does not leak below PeriscopeTools
(no imports in Core/UI; Package.swift:94-99 lists it only on Tools).

Validation: docs only. swiftformat --lint and attribution --check pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant