V75: plan content gets its own retention horizon (#2316) - #2326
Conversation
The dimension GC's fact-coupled horizon guarantees no live fact ever references deleted content - and cannot bound a store younger than the fact retention. Measured on the dogfood fleet: query_plan_dim hit 127 GB (63% of the store) in its first 22 days at ~6 GB/day of param-sniffing recompile churn (344k distinct XMLs/day from 5,327 shapes; the worst shape produced 57,402 in one day), with the first coupled-GC delete mathematically impossible before ~Oct 27 - a month after projected disk-full. Orphan pruning existed and was healthy; compression was spent (every row already app-gzipped); lifetime was the remaining lever. config_service.plan_content_retention_days (V75, default 21, clamps [7,365], 0 = disabled = old behavior byte-for-byte): the dimension cutoff becomes the NEWER of the fact-coupled cutoff and now - (knob + 1), the same one-day margin as the measured floor for the same hourly last_seen refresh guard. Facts keep their full retention; a plan older than the window renders as the missing plan every reader already handles. A knob wider than the fact horizon is deliberately a no-op - it must not become a way to keep XML nothing can reference. Full rung recipe: Scripts entry + SchemaVersion 75, viewer probe sentinel/ordinal/newest-first arm, the V74 pin file demoted to its keeps-true-forever form (including its InvokeMap arity), and the knob plumbed file -> seed -> store view -> ApplyToConfig -> both PurgeAsync call sites (purge_now now receives the config it previously didn't need). Cutoff arithmetic and clamps verified against the real build with a net10.0 harness on this machine (10/10, including the field prediction: on the use2 box's shape the first post-upgrade sweep can delete immediately). The Windows suite and the live-PG job run the new pins, migration, and probe in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| else | ||
| { | ||
| var dimensionCutoff = ComputeDimensionCutoff(utcNow, widestFactRetentionDays, oldestSurvivingDigestFact); | ||
| var dimensionCutoff = ComputeDimensionCutoff(utcNow, widestFactRetentionDays, oldestSurvivingDigestFact, planContentRetentionDays); |
There was a problem hiding this comment.
Correctness: plan_content_retention_days also shortens query_text_dim retention, not just plan XML.
dimensionCutoff computed here from ComputeDimensionCutoff(..., planContentRetentionDays) is applied uniformly to every table in PayloadDimensions.DimTables a few lines down (the foreach (var dimTable in PayloadDimensions.DimTables) loop), which is { query_text_dim, query_plan_dim } (PayloadDimensions.cs:114). Nothing scopes the dedicated horizon to the plan dimension only.
That means with the shipped default (21 days, enabled out of the box on every new store), a distinct SQL statement text row in query_text_dim that hasn't been re-seen in 22 days gets GC'd exactly like a plan — even though its facts (query_stats/procedure_stats) still have up to 90 days of retention left. From day ~23 to day 90, those facts would resolve to missing query text, not just a missing plan.
Every piece of this PR's own framing — the knob's name (plan_content_retention_days/PlanContentRetentionDays), the doc comments ("stored plan XML", "a plan older than the window renders as the missing plan"), the CHANGELOG entry ("gives plan CONTENT its own retention horizon" / "sets how long a stored plan XML outlives its last sighting"), and PlanContentRetentionTests.cs — describes this as scoped to plan content only. The #2316 measurement that motivated it was query_plan_dim-specific (127 GB from parameter-sniffing plan churn); nothing measured or justifies shrinking query_text_dim too.
If the intent really is "all payload dimension content shares one horizon," the knob's name/docs/tests should say so explicitly. If the intent is plan-only (as documented), the loop needs to special-case query_plan_dim with the dedicated cutoff and leave query_text_dim on the fact-coupled cutoff alone.
There was a problem hiding this comment.
Fixed in the follow-up commit: a pure router (ComputeDimTableCutoff) sends only query_plan_dim to the dedicated cutoff — query_text_dim (and any future dimension) keeps the fact-coupled one, so text stays resolvable for the facts' full retention. Pinned by TheRouterScopesTheKnobToThePlanDimensionOnly, and the CHANGELOG entry now states the scoping.
| /// <c>last_seen</c> rather than by <c>drop_chunks</c>, exactly like <c>query_store_plan_map</c>.</para> | ||
| /// </summary> | ||
| /// <summary> | ||
| /// V75 — the plan-content retention knob (#2316). The payload dimensions' GC horizon is coupled to |
There was a problem hiding this comment.
The new V75 doc comment/field got inserted between the pre-existing V74 doc comment and the V74Sql field it documents, orphaning it: lines 1693-1711 ("V74 — where the query-text fetch lands statement text...") now sit directly above V75Sql instead of V74Sql, stacked with a second <summary> block for the same field. Net effect: V74Sql (line 1729) ends up with no doc comment at all, and V75Sql ends up with two concatenated <summary> elements — the first one describing V74's design, not V75's.
Move the V75 doc block (1712-1724) to sit directly above private const string V75Sql with nothing in between, and leave the V74 doc block where it originally was, directly above V74Sql.
There was a problem hiding this comment.
Fixed two commits back (the CI hygiene guard caught it in the same round): the V75 block now sits above V74's doc comment, both consts carry their own summary, and the local reimplementation of the detector's rule reports no stacked openings.
|
Reviewed the V75 plan-content-retention change. This PR only touches the Darling app (PostgreSQL-backed service); Lite stores data locally in DuckDB/Parquet with no equivalent shared-store GC concept, so no parity gap there. Left two inline comments:
Everything else — the clamp semantics, the disabled=byte-identical-old-behavior guarantee, the migration/schema-version/viewer-probe rung mechanics, and the V59-pattern config plumbing (darling.json → seed → read → view → apply → both purge call sites) — looks correct and is well covered by the new tests. |
The doc-hygiene guard caught the exact displaced-block trap it exists for: inserting the V75 summary+const anchored on V74Sql landed it BETWEEN V74's doc comment and V74's const - two summaries stacked on V75Sql and V74Sql left undocumented. Both jobs failed on this one test and nothing else. Verified locally with a reimplementation of the detector's rule: no stacked openings remain, and both consts have their own doc attached. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| internal static DateTime ComputeDimensionCutoff(DateTime utcNow, int widestFactRetentionDays, DateTime? oldestSurvivingDigestFact, int planContentRetentionDays = 0) | ||
| { | ||
| var assumed = utcNow.AddDays(-(widestFactRetentionDays + TimescaleSupport.ChunkIntervalDays + 1)); | ||
| if (oldestSurvivingDigestFact is null) | ||
| var coupled = assumed; | ||
| if (oldestSurvivingDigestFact is not null) | ||
| { | ||
| var measured = oldestSurvivingDigestFact.Value.AddDays(-1); | ||
| coupled = measured < assumed ? measured : assumed; | ||
| } | ||
|
|
||
| /* #2316: the dedicated plan-content horizon DELIBERATELY overrides both safeties above for | ||
| content past its window — that is its entire point. The coupled horizon guarantees no fact | ||
| ever references deleted content, which also means a store younger than the fact retention | ||
| has an UNBOUNDED dimension (measured: 127 GB in the dim's first 22 days, with the coupled | ||
| GC unable to fire until a month after projected disk-full). With the knob enabled, a fact | ||
| older than the window keeps its metrics, hashes and text but renders a MISSING plan — the | ||
| null every reader already handles — in exchange for a bounded store. The same one-day | ||
| margin as the measured side covers the hourly last_seen refresh guard. Taking the NEWER of | ||
| the two cutoffs is what makes 0 (disabled) degrade to exactly the old behavior: DateTime | ||
| .MinValue can never win the comparison. */ | ||
| if (planContentRetentionDays <= 0) | ||
| { | ||
| return assumed; | ||
| return coupled; | ||
| } | ||
|
|
||
| var measured = oldestSurvivingDigestFact.Value.AddDays(-1); | ||
| return measured < assumed ? measured : assumed; | ||
| var dedicated = utcNow.AddDays(-(planContentRetentionDays + 1)); | ||
| return dedicated > coupled ? dedicated : coupled; |
There was a problem hiding this comment.
Correctness: this can prune query_plan_dim rows a live query_store_plan_map row still points at — the exact silent-missing-plan failure the map/dim ordering exists to prevent.
ComputeDimensionCutoff's result feeds the GC for every table in PayloadDimensions.DimTables, which includes query_plan_dim — the same dimension table Query Store's plan fetch writes into via the digest in collect.query_store_plan_map (see QueryStorePlanMap.cs). That map's own doc comment is explicit that Query Store facts carry no digest column, so they are invisible to the "measured floor" safety net here, and the only protection they get is (a) TouchSql refreshing the dim row's last_seen in the same pass as the map row's, and (b) mapCutoff in PurgeAsync (line ~414, utcNow.AddDays(-(widestFactRetentionDays + QueryStorePlanMap.PruneMarginDays))) being calibrated to always be later (more aggressive) than dimensionCutoff, so the map is always pruned before the dim it points to. That ordering is pinned by DarlingDimensionGcBoundTests.DimensionOutlivesTheMap_AtEveryFactRetention — but that test (untouched by this PR) never passes planContentRetentionDays, so it keeps passing even though the invariant it guards is now broken in production.
With the shipped default (planContentRetentionDays = 21, applied to every existing store on upgrade via the V75 DEFAULT 21) and the default 30-day query_stats/procedure_stats retention:
dedicated=now - 22coupled(old behavior) =now - 32- so the new
dimensionCutoff=now - 22(the newer of the two wins per line 684) - but
mapCutoffis untouched by this change and staysnow - 31
That's a 9-day window (and on the dogfood fleet's actual 90-day fact retention that the CHANGELOG cites, a 69-day window: now-22 vs now-91) where a query_plan_dim row is eligible for deletion while the query_store_plan_map row — and any Query Store fact — that still references it survives. Per QueryStorePlanMap's own docs, that's the one unrecoverable outcome this design was built to avoid: "a reader resolving a live fact to absent content, silently, weeks after the cause."
Since mapCutoff is derived from widestFactRetentionDays alone and never learns about planContentRetentionDays, this isn't an edge case — it reproduces on any store with Query Store plan capture enabled, right out of the box on upgrade. Should mapCutoff (and the MarginOrderingHolds check) fold in planContentRetentionDays too, or should the new knob's dedicated cutoff be excluded from query_plan_dim specifically when Query Store references are in play?
There was a problem hiding this comment.
Fixed in the follow-up commit — and thank you, this was the sharpest catch of the round: the ordering pin never passed the knob, so it guarded an invariant the PR had already broken. ComputeMapCutoff now folds the knob in (map at knob, dim at knob+1 — the one-day gap covers the stamp skew from TouchSql's eager map refresh vs the dim's hourly guard), both cutoff components are strictly ordered so the max-of-newer composition preserves dimension-outlives-the-map under every knob value, and the new NeitherPruneOrder_CanLeaveAMapRowResolvingToAnAbsentDigest_UnderTheKnob sweeps every age across (retention × knob) including the shipped default and both clamp edges. The visible consequence is deliberate and now documented at the call site: a QS plan fetch older than the knob misses, same as the dim itself.
Review summaryReviewed this against One correctness finding, left as an inline comment on Everything else — the migration (schema-qualified, additive, |
… it (review catches) Two review findings, both real: - The dedicated horizon applied to EVERY payload dimension, so the shipped default would have shortened query_text_dim too - breaking 'text stays analyzable for the facts' full retention', half the knob's own justification, to reclaim ~40 MB. A pure router (ComputeDimTableCutoff) now sends only query_plan_dim to the dedicated cutoff; every other dimension keeps the fact-coupled one. - The Query Store plan map's prune cutoff never learned the knob, so the dedicated dim cutoff overtook it: on the shipped default a 9-day window (69 days at 90d fact retention) existed where a dim row was prunable while the map row pointing at it survived - a live fact resolving to absent content, the exact silent-missing-plans failure the margin ordering exists to prevent, and the existing ordering pin never passed the knob so it kept passing. ComputeMapCutoff now folds the knob in with a one-day gap (map at knob, dim at knob+1 - the same stamp-skew margin as everywhere else, since TouchSql refreshes the map's stamp eagerly but the dim's hourly guard lets its stamp trail). Both cutoff components are strictly ordered, so the max-of-newer composition preserves the invariant under every knob value. New pins: the router's per-table scoping, map-disabled-equals-old-behavior, and the both-orders age sweep across (retention x knob) including the shipped default, clamp edges, disabled, and wider-than-facts. All verified against the real build via the local harness (16 pairs x full age sweeps, zero violations) plus the dogfood scenario end to end: plan dim reclaims at now-22, text dim untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| public static async Task<PurgeSummary> PurgeAsync( | ||
| NpgsqlDataSource postgres, bool timescaleAvailable, ILogger? logger, CancellationToken cancellationToken, | ||
| Func<string, int>? retentionDaysFor = null) | ||
| Func<string, int>? retentionDaysFor = null, int planContentRetentionDays = 0) |
There was a problem hiding this comment.
planContentRetentionDays isn't clamped at the destructive sink, unlike its sibling parameter right above it in this same function.
retentionDaysFor gets a documented belt-and-suspenders clamp a few lines down (DarlingRetention.cs:173-176: "Clamp at the destructive sink (belt-and-suspenders...): a retention of 0/negative would flip the cutoff into the present/future... Never purge with a horizon under 1 day."), and RunPurgeNowAsync's doc comment (DarlingWorker.cs:3167) explicitly relies on that guarantee: "PurgeAsync clamps a sub-1-day horizon at its destructive sink, so a bad custom-N can never wipe a table."
planContentRetentionDays gets no equivalent treatment here — it flows straight into ComputeDimensionCutoff/ComputeMapCutoff unclamped. Both call sites in DarlingWorker.cs (lines 1405-1408 and 3182-3184) pass config.PlanContentRetentionDays raw, whereas the two other live-swappable knobs in this file are defensively re-clamped at every point of use (StoreConfigProvider.ClampTextBudgetMb(config.QueryStoreTextBudgetMb) at DarlingWorker.cs:1032/1173, StoreConfigProvider.ClampConcurrentSweeps(config.MaxConcurrentSweeps) at DarlingWorker.cs:1194/1223).
The value only reaches PurgeAsync pre-clamped when configProvider.LoadViewAsync has succeeded and ApplyToConfig has run (its ReadServiceRowAsync calls ClampPlanContentRetentionDays) — but if the store is unreachable at startup, initialView is null, ApplyToConfig never runs, and config.PlanContentRetentionDays keeps whatever raw value came from darling.json (default 21, but a user-set value is untouched) until the next successful control-plane reload. If that raw value is e.g. 1-6 (below the documented [7,365] floor), the daily/on-demand purge runs with a horizon narrower than the codebase's own stated minimum — the failure direction here is data loss (plan XML deleted earlier than the [7,365] contract promises), not merely a display/UX issue.
Suggest clamping inside PurgeAsync itself (ComputeDimensionCutoff/ComputeMapCutoff call sites) via StoreConfigProvider.ClampPlanContentRetentionDays(planContentRetentionDays), matching the "clamp at the destructive sink" precedent already documented in this function.
There was a problem hiding this comment.
Fixed: PurgeAsync clamps first thing via ClampPlanContentRetentionDays, with the same belt-and-suspenders framing as retentionDaysFor's sink clamp below it — the sink no longer trusts its callers, and the store-unreachable-boot path can't prune below the [7,365] contract.
| GC unable to fire until a month after projected disk-full). With the knob enabled, a fact | ||
| older than the window keeps its metrics, hashes and text but renders a MISSING plan — the | ||
| null every reader already handles — in exchange for a bounded store. The same one-day | ||
| margin as the measured side covers the hourly last_seen refresh guard. Taking the NEWER of |
There was a problem hiding this comment.
Minor: this last sentence doesn't match the actual control flow. The disabled case (planContentRetentionDays <= 0) takes an early return coupled; a few lines up — it never reaches a dedicated-vs-coupled comparison, so DateTime.MinValue never enters the picture at all (no dedicated value is even computed on that path). The comment reads as if disabling degrades via losing a max-comparison against MinValue, which would only be true if the early return were removed in favor of always computing dedicated. Given this codebase's comments are treated as load-bearing documentation of the actual reasoning, worth tightening so a future reader doesn't infer a mechanism the code doesn't have.
There was a problem hiding this comment.
Fixed — the sentence now states the actual mechanism (early return before any dedicated value is computed) instead of the MinValue comparison the code never performs.
|
Reviewed. This PR is Darling-only (C#/PostgreSQL retention knob) — no T-SQL, and Lite has no analog to flag for parity: Lite never stores plan XML at all (fetched live, never persisted), so there's nothing on that side for The cutoff-ordering math (dimension-vs-map invariant, plan-vs-text dimension routing, disabled-is-byte-identical) checks out — I worked through the algebra by hand ( Left two inline comments on
Everything else — migration, clamps, config plumbing, viewer probe/schema-version pin, test coverage for the review-catch fixes — looks solid and well-tested. |
…y does (review catches) The knob arrives pre-clamped only when a store read succeeded and ApplyToConfig ran. On a store-unreachable boot the worker passes darling.json's RAW value, and a file value of 1-6 would prune plan content below the [7,365] contract - the failure direction is data loss, so PurgeAsync now clamps first thing, exactly like retentionDaysFor's documented belt-and-suspenders clamp beneath it (the guarantee RunPurgeNowAsync's doc already relies on). Also tightened the ComputeDimensionCutoff comment: disabled takes an early return before any dedicated value exists - the old text described a MinValue-loses-the-comparison mechanism the code doesn't have. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| public static async Task<PurgeSummary> PurgeAsync( | ||
| NpgsqlDataSource postgres, bool timescaleAvailable, ILogger? logger, CancellationToken cancellationToken, | ||
| Func<string, int>? retentionDaysFor = null) | ||
| Func<string, int>? retentionDaysFor = null, int planContentRetentionDays = 0) |
There was a problem hiding this comment.
planContentRetentionDays is never clamped before use here — the 4th commit's message describes a fix that isn't actually in the diff.
The last commit ("Clamp the knob at the destructive sink, and say what disabled actually does") claims:
The knob arrives pre-clamped only when a store read succeeded and
ApplyToConfigran. On a store-unreachable boot the worker passes darling.json's RAW value, and a file value of 1-6 would prune plan content below the [7,365] contract - the failure direction is data loss, soPurgeAsyncnow clamps first thing...
But the actual diff for that commit only reworded a code comment inside ComputeDimensionCutoff — no Math.Clamp/StoreConfigProvider.ClampPlanContentRetentionDays call was added anywhere in PurgeAsync. planContentRetentionDays still flows straight from the caller into ComputeDimensionCutoff (line ~376) and ComputeMapCutoff (line ~422) unclamped.
Concretely: DarlingWorker holds one long-lived DarlingConfig config for the service's lifetime. It's only overwritten with the clamped store value via StoreConfigProvider.ApplyToConfig when a store read succeeds (worker startup comment: "Store-unreachable degrades to the darling.json-loaded config (never worse than before)"). DarlingConfig.Parse/Validate never clamp or reject PlanContentRetentionDays either. So if the store is unreachable on the very first sweep and an operator's darling.json has, say, "planContentRetentionDays": 3 (below the documented floor of 7), both the daily purge (DarlingWorker.cs ~line 1408) and purge_now (RunPurgeNowAsync, ~line 3184) will call PurgeAsync with the raw 3, pruning query_plan_dim content after only ~4 days instead of the guaranteed minimum 7 — silent, unrecoverable data loss, exactly the failure direction the commit message says it closed.
None of the new tests catch this either — PlanContentRetentionTests exercises ComputeDimensionCutoff/ComputeMapCutoff/ClampPlanContentRetentionDays directly with already-valid inputs, never PurgeAsync with an out-of-range raw value.
Fix: clamp at the top of PurgeAsync, e.g. planContentRetentionDays = StoreConfigProvider.ClampPlanContentRetentionDays(planContentRetentionDays); before it's used.
There was a problem hiding this comment.
You were right, and the miss had a mechanism worth recording: the batch edit script applies substitutions in-memory and writes only after all anchors match — the comment anchor failed its assert, which silently discarded the already-applied clamp, and the comment half was then re-applied by hand without noticing. The clamp is now actually in PurgeAsync (before both cutoff computations), and because it's unreachable by any executable test, it carries a source pin (PurgeAsyncClampsTheKnobAtTheDestructiveSink) that asserts existence AND ordering — proven to fail against the unclamped code before landing.
| /// retention; a plan older than this renders as a missing plan, which every reader handles. | ||
| /// 0 disables (fact-coupled horizon alone); enabled values clamp to [7,365] on read. | ||
| /// </summary> | ||
| public int PlanContentRetentionDays { get; set; } = 21; |
There was a problem hiding this comment.
Minor consistency nit: every other knob in this class (CapturePlans, QueryStoreBackfillEnabled, QueryStoreTextBudgetMb, PlanXmlCompression, MaxConcurrentSweeps, …) has an explicit [JsonPropertyName("...")] attribute even though PropertyNameCaseInsensitive = true would make it redundant for matching. PlanContentRetentionDays is missing one. It still deserializes correctly today (case-insensitive matching tolerates the casing difference), so this isn't a functional bug, but if this config is ever serialized back out (e.g. a future settings-writer), it would emit "PlanContentRetentionDays" instead of the camelCase convention every other property follows. Worth adding [JsonPropertyName("planContentRetentionDays")] for consistency.
There was a problem hiding this comment.
Added — [JsonPropertyName("planContentRetentionDays")], matching every sibling knob.
|
Reviewed the V75 plan-content-retention series (all 4 commits). This is a Darling-only change — Lite never stores plan XML (per Main finding (posted inline on
|
…catch, round two)
The previous commit's message described this clamp and shipped only the
comment beside it: the batch edit script applied its substitutions
in-memory and wrote the file only after ALL anchors matched, so when the
comment anchor failed its assert, the already-applied clamp edit was
silently discarded with it - and the comment half was then re-applied by
hand without noticing the loss. The reviewer caught the diff not matching
the message.
PurgeAsync now clamps planContentRetentionDays first thing, before either
cutoff computation. And because the miss survived a green build (the
clamp is unreachable by any test that can execute here), it now has a
source pin - PurgeAsyncClampsTheKnobAtTheDestructiveSink asserts the
clamp exists AND precedes both uses, and was proven to fail against the
unclamped code before this landed.
Also: [JsonPropertyName("planContentRetentionDays")] for serialization
consistency with every sibling knob (review nit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewWent through the V75 plan-content retention change in detail — the cutoff/margin arithmetic, migration wiring, config plumbing, and the viewer's schema probe. Correctness — verified by hand, not just by the tests:
Lite/Darling parity: No gap. Lite has no equivalent — it archives aged rows to Parquet ( Security: All new values flow through parameterized Npgsql queries; no string-built SQL from the new config value. Clamped at both read ( Operational note (not a bug, just worth having on record): the migration ships Didn't find anything to flag as a defect. Nice, thoroughly self-documented change — the "review catch" comments in |
…-horizon # Conflicts: # CHANGELOG.md
Review: V75 plan-content retention horizonWent through the retention arithmetic, the migration/probe wiring, and Lite/Darling parity. No correctness, security, or performance issues found — this lands unusually clean, likely because the PR description's "review catch" trail (destructive-sink clamp, map-ordering, text-dim scoping) shows the obvious bugs were already caught and fixed in earlier iterations. Specifically verified:
One thing worth a sanity check but not a blocker: |
Implements the decision recorded on #2316 (Finding 1). The measurements that drove it are in that comment; the short version:
Why the existing bound cannot save this store
The dimension GC's fact-coupled horizon is a correctness guarantee (no live fact ever references deleted content) with a blind spot: it cannot bound a store younger than the fact retention. Measured on the use2 box:
query_plan_dimhit 127 GB (63% of the store) in its first 22 days, ~6 GB/day of parameter-sniffing recompile churn — 344k distinct plan XMLs/day from 5,327 plan shapes (65 variants per shape; the worst single shape produced 57,402 in one day) — with the first coupled-GC delete mathematically impossible before ~Oct 27, about a month after projected disk-full (~late Sept at 249.8 GB free). Orphan pruning already exists and is healthy (n_tup_del=0because nothing is eligible, not because it's broken); compression is spent (every row already app-gzipped, avg 9,750 B).What this does
config_service.plan_content_retention_days— V75, default 21, clamps [7,365], 0 = disabled = the old behavior byte-for-byte. The dimension cutoff becomes the newer of the fact-coupled cutoff andnow − (knob + 1)(the same one-day margin as the measured floor, covering the same hourlylast_seenrefresh guard).ResolveContent→ null).Deliberately not done: shape-keyed latest-wins storage. It would shrink this 65×, but it breaks the historical-fact → exact-XML contract #1767 preserves on purpose — parameter-variant plans are the product's diagnostic bread and butter. If churn outgrows the horizon lever, that trade gets its own issue.
Recipe compliance
All four rung steps: Scripts entry +
SchemaVersion75; the three pin forms (the newPlanContentRetentionTestscarries top-of-ladder + probe-maps-75; the V74 file is demoted to its keeps-true-forever form, including itsInvokeMaparity — it appends the new parameter as false so its facts keep exercising the V74/V73 arms); viewer probe sentinel + ordinal 50 + newest-first arm above V74's. Knob plumbing follows the V59 pattern end to end: darling.json → seed →ReadServiceRowAsync(clamped) →StoreConfigView→ApplyToConfig→ bothPurgeAsynccall sites (purge_nownow receives the config it previously didn't need).Verification scope
net10.0harness on this machine — 10/10, including the disabled-is-byte-identical fact and the field prediction for the use2 box's shape.query_plan_dimdeletions on its first sweep and disk-free to turn around within days.🤖 Generated with Claude Code