Skip to content

Model traces: record what each model was shown - #519

Draft
Lazarus-931 wants to merge 20 commits into
Blaizzy:mainfrom
Lazarus-931:feature/nativ-trace
Draft

Model traces: record what each model was shown#519
Lazarus-931 wants to merge 20 commits into
Blaizzy:mainfrom
Lazarus-931:feature/nativ-trace

Conversation

@Lazarus-931

Copy link
Copy Markdown
Collaborator

Records an append-only trace of every model call — system prompt by provenance, tools with their origin and schemas, thinking, tool calls and results — one trace per model instance per chat, readable from the chat page's existing side panel.

Every worktree builds to Nativ.app, so parallel checkouts overwrite each
other's output and it is not obvious which bundle came from which branch.

PRODUCT_NAME now reads NATIV_PRODUCT_NAME, which defaults to Nativ in
Signing.xcconfig and can be overridden in the git-ignored
Signing.local.xcconfig or on the command line:

    make xcode-run NATIV_PRODUCT_NAME=nativ-trace

The Makefile threads the same variable through the build, sign, run, and
smoke targets so the paths follow the rename. CFBundleDisplayName follows
it too, so a renamed build is distinguishable in the Dock as well as in
Finder.
The dashboard can say how a request performed but not what the model was
actually given. That matters most for calls Nativ did not compose — the
coding agents hitting the local server — where the system prompt, the
injected skills, and the advertised tool schemas are otherwise invisible.

This lands the format and the storage, with no producers or UI yet.

A trace is an append-only log of JSON events. Everything a reader shows is
folded from that log by TraceReducer and can be discarded and rebuilt, so
no derived state is ever authoritative. The properties that let a trace
outlive the build that wrote it are stated in Sources/NativTrace/README.md
and pinned by TraceFormatCompatibilityTests:

- Event kinds and origins are open string-backed types, not closed enums,
  so a trace written by a newer build decodes here instead of throwing.
- Payloads are TraceJSON, so fields this build does not know survive read,
  re-encode, and export rather than being dropped.
- An unrecognised event becomes a visible row, never a shorter transcript.
- Compression is a storage detail; an exported trace is always plain JSON.
- Migrations are named and append-only; trace_index is rebuildable.

Provenance is captured where the prompt is composed rather than parsed back
out of a flattened string, so a PromptSection knows it came from a skill or
a project instead of being recovered by matching markers.

NativTrace imports Foundation and SQLite3 and nothing else, and carries its
own test target, so the suite runs in 0.05s without building the app.

    xcodebuild test -project Nativ.xcodeproj -scheme NativTrace \
      -destination 'platform=macOS,arch=arm64'
NativTrace could store an exposure but nothing produced one. This wires the
chat loop to it, so a trace now exists for real activity.

Provenance is captured where the request is assembled. makeCompletionRequest
returns a ComposedChatRequest carrying both the wire request and a description
of how it was built: which span of the system prompt came from the user's
settings, a project, the tool guide, or a named skill, and whether each tool
was built in, user-defined, or supplied by a specific MCP server. None of that
is recoverable from the request body, which is why it is recorded here rather
than reconstructed later.

Two details the obvious wiring gets wrong:

- makeCompletionRequest is called up to three more times per round by
  fittedDocumentContext, purely to measure tokens against the context limit.
  Emitting from inside it would record four exposures per model call, so the
  event is emitted from runChatLoop, which knows which request is real.

- Producers hand events to an AsyncStream drained by one consumer rather than
  firing a detached Task each. Independent tasks reach an actor in scheduler
  order, which would let a tool result be written before the call it answers.

Tool events come from insertToolMessage and updateToolMessage, the two funnels
every dispatch path already goes through, so consent denials and cancellations
are recorded without touching each branch. Only terminal statuses are written,
keeping one row per call.

Streaming stays cheap: deltas accumulate in the recorder and are persisted only
if a call never completes, since a completion carries the authoritative text.
A completed call costs one row instead of one per token.

Also adds the remaining fold stages — TraceGrouping for turns, model-switch
boundaries, and collapsed tool runs; TraceExposureResolver, which makes the
message-reference scheme real by resolving refs against the trace and flagging
bodies whose hash no longer matches what was sent; and TraceExposureDiff, which
answers what changed in the model's exposure between two calls.

54 tests, still no app build required to run them.
A quality pass over the trace subsystem. Two of these were real defects, not
tidying.

Sequence allocation was a check-then-act across a suspension point.
reserveSequence handed a number to the caller, who appended with it in a
separate actor call. Nothing exploited the gap yet because one recorder existed,
but the API invited a second. TraceStore.record now allocates and appends in one
call, and reserveSequence is gone. insert(preSequenced:) remains for importing
an exported trace and for tests, named so it reads as the unusual path.

TraceRecorder spawned a Task per write. That returned immediately and let the
next event reach the store first — the exact reordering the producer's queue
exists to prevent, reintroduced one layer down. It now awaits the store.

Structure:

- The ordering machinery moved out of ChatTraceProducer into
  NativTrace.TraceEventQueue. It was never chat-specific, the local-server
  producer will need it, and in the app target it could not be tested without
  building the app. It now has tests asserting 200 events keep their order and
  that a tool result never overtakes its call.
- TraceStore delegates to TraceIndex and TraceRetentionSweep; it was doing
  storage, index maintenance, and pruning in one 374-line type.
- Models moved from a JSON column on trace_index to a trace_models child table.
  The column forced a read, a union, and a write back per trace per batch; the
  table makes it an INSERT OR IGNORE.
- TraceExposureResolver became TraceExposureIndex, built once per transcript.
  Resolving each of a turn's exposures against a freshly walked item list was
  quadratic in turn length — the cost the reference scheme exists to avoid.
- SQL goes through withStatement, which resets on entry and exit, refuses
  re-entrant use, and caches prepared statements. Parameters bind in order
  rather than by index, so adding a column cannot shift a value into the wrong
  placeholder.
- ChatTraceCall and ChatTraceTurn replace repeated sessionID/turnID/requestID
  parameters, where a transposed pair would compile and file events under the
  wrong turn.
- Merged TracePayloads.swift, whose name said nothing, into the two files whose
  subjects it shared.

Behaviour:

- Tool consent is recorded. The reducer already modelled it and nothing emitted
  it, so a denial was stored as a failed tool result — reading as a broken tool
  rather than a choice the user made.
- recordToolOutcome switches exhaustively on ToolStatus, so a new status is a
  compile error instead of an unrecorded outcome.

61 tests, still without building the app.
Closes the gaps left after the structure pass.

Routines are traced. RoutineRunner assembles its prompt separately from chat —
tool guide plus resolved skills, tools tagged from ScheduledTool.Provider — so
it needed its own exposure builder rather than a shared one that would have to
know about both shapes.

Retention is configurable: traceRecordingEnabled, traceRetentionDays, and
traceMaximumTraces on NativSettings, projected to a TraceRetentionWindow.
Turning recording off yields .none, so the next sweep also clears what is
already on disk rather than leaving it stranded.

Adds --trace-smoke-test, following the existing --smoke-test convention, run by
`make xcode-trace-smoke`. It drives the real store, recorder, queue, fold, and
resolver inside the built app. That is the check the per-stage unit tests cannot
make, and it immediately earned its keep twice:

- It deadlocked on the first attempt because the pipeline runs on the main actor
  and the harness blocked that thread waiting for it. Now dispatchMain.
- It caught the recorder sealing a streamed response on any non-delta event. A
  tool call happens *within* a call, so sealing on one stored the partial text
  and then stored it again with the completion. Sealing is now limited to kinds
  that actually end a call's output, expressed as
  TraceEventKind.sealsStreamedOutput rather than an inline comparison.

Also adds MLXChatMessageContent.plainText, since two call sites now need the
text of a message that may be parts.
Chat gains an inspector (View ▸ Show Model Trace, ⌘⌥T) and the dashboard's
request sheet gains a Trace tab. Both render the same fold over the same store;
one enters at a session, the other at a single request.

The exposure card is the point of the feature. Collapsed it reads "Call 2 ·
4 prompt sections · 14 tools" with badges for what changed since the previous
call — a tool added, a schema silently redefined, a prompt section edited.
Expanding walks down: sections, then a section's text with its provenance
labelled from the recorded origin rather than guessed from the prose; tools,
then a tool's advertised schema; the conversation as sent, with any message
whose hash no longer matches marked "edited since" instead of being presented
as what the model saw.

Deliberate choices:

- Selecting a call scrolls its exposure into view instead of swapping the pane,
  so a call stays inside the conversation it belongs to.
- The transcript switches exhaustively on TraceItem.Body, including .unknown, so
  an event from a newer build occupies a visible row rather than making the
  transcript quietly shorter than the trace.
- TraceInspectorViewModel does the folding, resolving, and diffing once per load.
  SwiftUI calls body far more often than a trace changes.
- Settings states that traces never leave the machine, and offers retention plus
  an explicit delete. "Record what the model was shown" honestly means "store my
  prompts", and that should not have to be inferred.

The view model takes an injectable store, so --trace-smoke-test drives the real
inspector rather than a copy of its logic. That is now 17 checks covering
producer through rendered blocks.

Built locally as nativ-test-model-trace.app via the NATIV_PRODUCT_NAME knob.
An exposure row whose payload failed to resolve rendered nothing. The fold
guarantees every event occupies a row, and the framework README says so; the one
place that could silently drop one was the view. It now shows the call with an
explicit failure detail, because unresolvable exposure is a bug worth seeing.

Other fixes from the same pass:

- The user message bubble was a Capsule, which turns a multi-line prompt into a
  lozenge with the text pushed out of its own corners. Now a rounded rectangle.
- TraceCallSidebar set .tag on rows of a List already keyed by Identifiable.id.
- System prompt and tools now start expanded inside an opened call. Three clicks
  to reach the text people opened the inspector to read was too many.
- The inspector reloaded once and went stale while a chat continued. It now
  reloads on activeRequestSessionID, which changes at turn boundaries rather
  than per token.
- Recorded size ignored the WAL, understating usage right after recording, and
  reached it through a double optional.
- "Call N" was spelled out in two places; TraceCallLabel now owns it.
- Selection survival used `exposures[selectedCallID ?? ""]`, which asked the
  dictionary about the empty string when nothing was selected.
Trace identity was per chat, with model switches as dividers inside a single
transcript. It is now per model instance: traceID is "<session>/<instance>" and
a new trace opens when the model serving a chat changes. A chat holds a list of
traces, store.traces(forSession:) returns them oldest-first, and each folds
separately — two models shown different prompts and different tools are two
things to reason about, not one transcript with a line through it. A trace that
opens mid-turn replays the prompt so it reads standalone.

Placement: the trace is a second pane inside the chat page's existing
right-hand panel, chosen by a segmented control in its header. The
sidebar.right button that already toggles that panel is the only way in;
nothing new sits on the page and nothing appears unbidden. The .inspector
column is gone. ⌘⌥T now reveals that pane rather than opening a surface of its
own.

Fixes two defects this redesign introduced, both found by review:

- Resolution was per trace, so a call made after a model switch could not see
  the history it referenced and reported the whole conversation as "content not
  retained" — precisely the workflow per-model traces exist for. Folding stays
  per trace; the exposure index now spans the chat.
- traceID(session:modelID:) allocated state and replayed a prompt, and was
  called from delta() per streaming chunk. A straggler carrying another model
  would mint a one-event trace and add a phantom entry to the model picker.
  Only sessionStarted, turnStarted, and requestComposed may open a trace now;
  everything else joins the open one or is dropped.

Also reverts the client-supplied request id. record_event uses INSERT OR IGNORE
and returns early when the row already exists, so letting a caller name the
primary key meant a reused id would silently discard the metrics row and its
hourly and daily aggregates. Joining traces to metrics needs a separate
non-key column, not a borrowed one.

Two same-labelled generic initialisers on ModelConfigurationLayout left
Auxiliary unbound at every call site and timed out type inference in ModelsView;
collapsed to one initialiser plus a constrained convenience.
…count

Every finding from the high-effort review, plus the design change that a model
instance's trace should read as a whole account of what that model saw.

Each model instance now carries its inherited context. A model that takes over a
chat is shown everything that came before, and its first exposure already
recorded all of it — but the transcript opened with an answer to a question it
appeared never to have been asked. The inspector now derives, per instance, the
messages the first call referenced that the instance never produced, resolves
them against the chat's other traces, and shows them as "carried over from
<model>". No new events: the data was already on disk, only unshown. Each trace
also opens by saying what it is, session_started or model_switched, which is
what sessionStarted and modelSwitched are for — they previously had no callers.

Correctness:

- The exposure recorded the raw transcript instead of what was sent. apiMessage
  drops error rows and empty assistant turns and folds document context into
  the body, so the trace listed messages the model never saw and hashed bodies
  that omitted the document — then called them verified. Refs are now built
  alongside the request, and a body that differs from the transcript is inlined.
- A turn that threw was recorded "completed": the defer inferred the outcome
  from Task.isCancelled, which is false for every failure that is not a
  cancellation. runChatLoop now wraps runTurn and records what happened.
- Cancellation was tested against CancellationError alone while this file's own
  catch clauses also accept URLError.cancelled, so stopping a response was
  recorded as a failure. Both paths share ChatIsCancellation now.
- turn_ended could never seal a streamed call. Turn events carry no requestID,
  so the key they derived never matched the key the deltas were stored under —
  a partial was never written and the reducer left a finished transcript
  rendering a live placeholder. Sealing is now by trace.
- turnStarted set the turn context before opening its trace, so openTrace
  replayed the prompt into the trace the caller was about to record it in.
  Traces now track which of them already hold a prompt.
- Recording could not be turned off. ChatView read the setting once at
  onAppear, and retention never applied the configured window because
  readableStore() consumed the single per-launch sweep with defaults. The
  setting is observed, the window is applied when it changes, and pruning no
  longer depends on a producer existing.
- flushAll had no route from the app, so quitting mid-generation dropped the
  partial the design promises to keep. It runs through the queue and is called
  before the server goes down at termination.
- Routine exposures gave every non-tool message the user prompt's id, so
  assistant rows resolved to the prompt text and collided as SwiftUI ids; the
  tool call and its result used different fallback ids, orphaning rows; and
  cancellation left the turn open forever. Refs are tracked as messages are
  appended, the call id is computed once, and the turn always ends.
- The dashboard's Trace tab could not work: it joined on the server's request
  id while the trace held the app's. request_events gains client_request_id — a
  plain column, never the key, because record_event uses INSERT OR IGNORE and a
  borrowed key would let a reused id discard a metrics row and its aggregates.
  The tab loads the enclosing trace and selects the matching call, since reading
  only the matching events excluded the prompt.
- open_macos_debug.sh hardcoded the executable "Nativ", so make xcode-run
  failed for a renamed build and pgrep -x Nativ terminated a different build
  than the one launching. It reads CFBundleExecutable.
- The Makefile told you to set the product name in Signing.local.xcconfig while
  always overriding it on the command line. It now reads the xcconfig chain and
  forwards an override only when one was actually given.

Cleanup: the reducer reimplemented TraceCallKey's format; RoutineRunner's
exposure ignored the systemPrompt it was passed and omitted repetition penalty,
thinking budget, and response format; encode failures were dropped silently and
are now counted; TraceRetentionWindow.none is TraceRetentionWindow.clearAll,
since days: 0 reads like "no limit" and means "everything"; and the
unreadable-payload test never constructed an unreadable payload — the codec's
unknown-encoding and corrupt-input paths are tested directly.

63 unit tests. The trace smoke test is 21 checks and now covers a model
handover: two traces, the second opening with model_switched, its inherited
message resolving from the first model's trace and verifying against what was
sent.
A trace is an append-only log of JSON events with no dependency on the app, and
that was worth being able to use. Prints the same thing the chat panel renders —
system prompt by provenance, tools with their origin, sampling, thinking, tool
calls and results, usage — from the command line.

    scripts/dump_trace.py            # list traces
    scripts/dump_trace.py --latest   # full transcript

Opens the store read-only and inflates raw DEFLATE payloads directly, so it can
be run against a live database while the app is recording.
The session boundary wrapped one character per line. Its label sits between two
flexible rules, and with no line limit the rules won the layout and compressed
the text to a character wide — so "Session started", a model id, and a full
timestamp each stacked vertically a letter at a time. Every text in that row is
now single-line, the model id truncates in the middle, the label holds layout
priority, and the timestamp drops the date it was repeating from the row above.

Every call was labelled "Call 1". roundIndex counts calls within a turn and
restarts at zero, so it cannot number a list that spans turns. Calls are now
numbered by position in the trace, with the round named only when the model is
actually looping on tools ("Call 3 · round 2").

A cancelled turn reported "0 rounds" after plainly making a call. runTurn only
returns a count on success, so a turn that threw lost the calls it had already
made. The count is tracked as the loop runs and read from there.

finish_reason rendered as raw API vocabulary next to prose — "tool_calls" under
a paragraph of English. Mapped to wording that matches its surroundings.

The lifecycle row had the same unbounded-text exposure as the boundary row and
got the same treatment.
A local build shares ~/Library/Application Support/Nativ with an installed copy,
so running one can change the other's settings, chats, analytics, and traces.
Twenty-three call sites resolve that directory; overriding HOME for the launch
isolates all of them without putting a dev-environment concern into product code.

The Hugging Face cache is symlinked rather than isolated — it is a terabyte
here, and a sandboxed home with no models is not worth launching.

    scripts/run_isolated.sh

Launches the executable directly rather than through `open`, since launchd would
not carry the overridden HOME.
This reverts commit d15de598a686dcf7d616083cf84ba4d8cef79b6f.
Four review passes over the trace subsystem — reuse, simplification,
efficiency, altitude — then applied what held up. One measurement overruled the
review I most expected to accept.

Correctness first:

- RoutineRunner emitted turn_ended from three places. The defer added earlier
  was meant to replace two explicit calls and they were never removed, so both
  the success and failure paths wrote two turn_ended events.
- Both exposure builders described what settings said rather than what was sent.
  The request gates thinkingBudget on speculative decoding and drops
  responseFormat when tools are advertised; the parallel mapping did neither, so
  a chat trace misstated the thinking budget whenever speculative decoding was
  on and never recorded the response format. SamplingParameters now reads the
  request it describes, which removes the drift by construction rather than by
  discipline — and deletes the mapping from both sites.
- NativTests compiles ChatViewModel and NativModel by path, both of which now
  reference NativTrace, but the target had neither the dependency nor the new
  files. It fails earlier on a pre-existing @testable import Nativ, so this was
  a landmine rather than a break; wired up regardless.

Deleted:

- Tool-run collapsing. An inspector should not hide tool rows behind
  "read_file x4" by default, and TraceToolRow is already individually
  collapsible — so a second enum, a wrapper struct, a fold, a label builder and
  a view existed to add one summary line. A test now asserts every tool row
  survives grouping.
- requestSent, RequestSentPayload, .wireRequest, TraceWireRequestRow and the
  reducer arm: a complete feature across two targets with no producer. The
  .unknown case already renders an unrecognised kind, which is the documented
  reason it exists, so a future producer loses nothing.
- Migration machinery running over an empty migration list, an unused second
  initialiser, and a trace_meta table nothing reads or writes. Every statement
  in baseSQL is CREATE IF NOT EXISTS, so the registry can arrive with its first
  real migration.

Efficiency:

- The exposure was built unconditionally, and fittedDocumentContext calls
  makeCompletionRequest up to four extra times per round for token preflight and
  discards the exposure each time. Now gated on something recording — so five
  builds per round become one, and none at all when recording is off.
- ModelConfigurationAuxiliaryPane called content() in its initialiser, so the
  trace pane was constructed on every ChatView body pass whether the panel was
  open or shut. NativModel ticks once a second. It holds a builder now, and
  drops the AnyView that was also defeating SwiftUI's diffing.
- The inspector reloaded twice per turn because its token flipped nil -> id ->
  nil, and the turn-start firing read a trace containing nothing from the turn
  it was reacting to. It follows a turn-completion count instead.
- callLabel(for:) scanned every call per row, so a full render cost C(C+1)/2
  iterations. Built once into a dictionary during the fold.

Kept, against the recommendation: the message-reference scheme. Storing bodies
inline measures 2.2x at 20 turns, 4.3x at 40, and 8.1x at 80 — quadratic,
because compression works per row and cannot dedupe across rows. The hash
verification stays too; it is what stops a reader being shown a body the model
never saw, and the answer to its cost is to memoise it, not to stop checking.

4780 -> 4548 lines. 61 unit tests, 21 smoke checks.
@Lazarus-931

Copy link
Copy Markdown
Collaborator Author
Screenshot 2026-09-07 at 1 23 11 PM

wip

@Yavaren

Yavaren commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Nice!

A second cutting pass over everything except the UI, which stays exactly as it
looks.

Deleted the second SQLite wrapper. NativAnalyticsStore carried a 189-line
private copy that the framework's is a strict superset of, differing in one
PRAGMA value. The framework's is now public — it has no dependencies, so both
sides can use it — and gained `at:`-labelled bind and read overloads so the
~88 existing call sites compile unchanged rather than being rewritten. It also
gained a documented `prepare` for the analytics reader, whose queries hold
several statements at once and cannot use the scoped form.

The analytics reader now opens read-only and declares no schema. The server is
the only writer of that database, yet this side kept a byte-identical CREATE
TABLE copy and re-applied an ALTER on every connection open — so a column
addition had to land in two places, the two had already drifted on which
indexes exist, and DashboardViewModel builds a fresh store per filter change,
making that a parse-and-fail per interaction. Before the server has ever run
there is now no file, which every fetch already answers with an empty summary.

Folded TraceBoundary into the item it was copied from. It carried the same
kind, title and detail as TraceLifecycleBody, with a Kind that was a two-case
subset of the same enum, and the row now reads the lifecycle body it already
had. The "Switched to X" / "from Y" phrasing moved to the view, where it
belonged. The row renders identically.

Dropped fields and cases nothing produced, each verified scoped to the
subsystem rather than by a grep that also matched the analytics code:
TraceUsage's five unused counters (both producers set only prompt and
completion tokens), TraceMessageRef.byteCount (written at every construction
site, read nowhere), ResolvedExposure.systemPromptText, TraceJSON's boolValue /
objectValue / isNull, PromptSectionOrigin.documentContext,
ToolOrigin.extensionProvided, and TraceRecorder's failureCount / lastFailure —
the logger was already the real surfacing, and it now logs the reason too.

Replaced the Job enum with a closure. Six cases and a six-arm pump restated
every recorder signature a third time; a single `run(@sendable (TraceRecorder)
async -> Void)` case keeps one signature per operation. The barrier stays an
explicit case, since a continuation is not a recorder call.

Folded the retention sweep into TraceIndex — one three-table delete had been
split across two types — collapsed the compression shim now that the two C
entry points can be used as values, dropped TraceDisplayBlock.loose (it needed
a non-lifecycle item with no turnID, which no producer emits, and such an item
now joins a turn rather than being dropped), and stopped storing each call's
diff twice.

Reverted mid-pass: consolidating TraceOriginChip into NativStatusBadge. It is
real duplication — the third fork of that pill — but the shared component uses
the app's system colours and a different opacity, so it changed how the trace
reads. Left as a follow-up for whenever the two palettes are reconciled.
A tool the user allowed read as still blocked on them. Approval has no status of
its own — it is the move off awaitingConsent — and nothing recorded that move,
so a terminal command showed a hand.raised icon and a "requested" chip while it
executed, and a run that never returned left the trace claiming it had been
blocked. recordToolOutcome runs before the message is mutated, so the message
still carries the status being left, which is exactly the transition to record.

That bug survived two attempted fixes because the vocabulary was String on both
sides of the producer/reducer seam. The reducer handled "approved" and
"cancelled", which no producer emitted, and gated a state transition on the
first — dead code that read as working. TraceTurnStatus and
TraceConsentDecision are enums now, so the reducer's switch is exhaustive and
the compiler enforces agreement. An unrecognised value fails the payload view,
which surfaces the event as .unknown rather than as one that silently means
nothing. ChatTurnOutcome, a private copy of the same idea, is gone.

Efficiency:

- TraceEventQueue encoded on the caller's actor and then yielded. Every
  producer is @mainactor, so a JSONEncoder pass over a whole tool output or a
  full answer ran on the main thread before the hand-off — the opposite of what
  the type documents. Encoding now happens on the consumer.
- Verification re-hashed the same message once per round it survived into,
  quadratic in turn length, on the main actor. TraceExposureIndex hashes each
  distinct body once at construction.
- loadSession issued a listing query and then one more per trace,
  sequentially. It now reads the chat's events in one query and groups them in
  memory.
- producer(enabled:) nilled the shared producer and recorder while callers
  cached the reference, so a routine running with recording off detached the
  chat until its view next appeared. It answers without tearing anything down.
Rule 5 described an append-only migration registry that no longer exists, and
the layout still listed migrations under Store/. Replaced with why there is no
registry: shipping one ahead of any migration to run through it invites drift
between the two.

Adds the rule the last fix earned — vocabulary crossing the producer/reducer
seam is typed, and why: kinds and origins stay open because they name things a
future build may add, but payload values are closed because both sides must
agree, and a String there drifted immediately.
# Conflicts:
#	Nativ.xcodeproj/project.pbxproj
#	Sources/Nativ/NativModel.swift
Lazarus-931 added a commit to Lazarus-931/nativ that referenced this pull request Sep 8, 2026
Per review: xcodegen rewrote Nativ.xcscheme's LastUpgradeVersion from 2660 back
to 1430 and version from 1.3 to 1.7, which makes Xcode offer a panel of
Recommended Project Changes, not all of which are correct. Reverted to what
main has.

The revert is not durable on its own — any `xcodegen generate` reintroduces it,
which is how it reached both this branch and Blaizzy#519. Untracking the scheme so it
is only a local reference would fix it for good, but that removes a shared file
from everyone's checkout, so it wants a maintainer's call rather than being
folded in here.
Same regression Luca flagged on Blaizzy#524: xcodegen rewrote Nativ.xcscheme's
LastUpgradeVersion from 2660 back to 1430 and version from 1.3 to 1.7, which
makes Xcode offer a panel of Recommended Project Changes that are not all
correct. Reverted to what main has.

Not durable on its own — any `xcodegen generate` reintroduces it, which is how
it reached both branches. Untracking the scheme so it is only a local reference
would fix it for good, but that removes a shared file from everyone's checkout.
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.

2 participants