Slice 2 — Arc-Layer (Spec 1, content + scope design)¶
Date: 2026-06-28
Status: Brainstorm-design, awaiting user review → writing-plan.
Parent: docs/specs/2026-06-15-story-arcs-corruption-design.md (the ratified Spec-1
architecture — §1 Arc-Templates, shared types, pickArc/arcAllows semantics, selection
lifecycle, ending evaluation). This doc does NOT re-design the architecture; it pins the
concrete creative content + the precise Slice-2 scope boundary that the parent left open.
Plan: docs/specs/2026-06-15-story-arcs-corruption-plan.md (Slice 2 row + anchors).
What this slice is¶
The arc architecture (types, pickArc, selection, ending resolution) is ratified. Slices 0+1
were infrastructure (tests, RNG parity, JSON SSOT) and shipped no user-visible feature.
Slice 2 is the first payoff: the story actually branches — different pacing, mentor
behaviour, beat flavour and ending across shifts.
Content ambition (ratified this session): "lean + tagged". Variability comes from routing + pacing + mentor presence + ending + tag-filtering of the EXISTING beat pool — no new antagonist prose block. Distinctive per-antagonist beat prose is a later content pass.
Scope¶
IN Slice 2:
- arc.ts twin: ArcTemplate / ArcState / EndingSpec types, arcAllows(), pickArc(),
selectArc(), resolveEnding().
- pickArc(POOL, this.arc) swept across the ~20 narrative pick sites (mechanical; pick()
itself untouched).
- Arc selection in the silentReset callback (weight + in-memory ring buffer N=3;
cold-start = pure weights; first shift deterministic-from-weights for test pinning).
- phaseRouting (acyclic forward only) + per-phase durationScale.
- Mentor override per arc (gate beatMentor on arc.mentor.availablePhases).
- threat→stage quantization (small pure function) for peakStage / divertOnThreat.
- Ending resolution at SILENCE (peakStage → first matching divertOnThreat, else
default); the resolved EndingId selects the SILENCE farewells/lastWords set.
- Beat tagging of a subset of existing beats + arc-specific farewells/lastWords per
ending (6 ending sets; normal = today's content unchanged).
- Additive invariant: arc=undefined ⇒ golden narrative byte-identical (web + native twin).
OUT (deliberately deferred):
- Corruption render layer → Slice 4 (only the threat→stage quantization lands here, not
the visual render).
- Arc scenes FilmDirector bias → Slice 7; threatCurve → Slice 8. The ArcTemplate
type carries these optional fields but Slice-2 templates omit them (no unconsumed data).
- Persistence of arcsCompleted/endingsReached/layerCounter → Slice 3 (ring buffer is
in-memory until then; cold-start path already handles empty history).
- "False all-clear" backward routing (e.g. ALARM→ROUTINE then re-escalate) — needs a
once-guard to avoid loops; deferred. Slice 2 does acyclic forward routing only, so the
additive invariant holds trivially.
Web/native split (mirrors Slice 1's 1a/1b/1.3 rhythm):
- Slice 2 web — now, autonomous: arcs authored in story-content.json, web reads them,
web tests (golden, arcAllows truth table, ending determinism).
- Slice 2 native — later, bundled with native 1.3 in an on-device session: native reads
the same arcs from the JSON (no double-authoring). The cross-platform LCG arc-parity
tests run once native lands.
The 5 arcs (selectable) + captured (divert-only)¶
Selectable arcs[] = normal, harmonized, cold-path, karsen, wraith. captured is not a
template — it exists only as an EndingId with its own farewells/lastWords, reached via
divertOnThreat.
| Arc | weight | antagonist | mentor (Karsen) availablePhases | routing / pacing | ending.default | divertOnThreat |
|---|---|---|---|---|---|---|
| normal | 1 | baseline | ROUTINE,INTRUSION,ALARM,PANIC | linear, ×1 | normal |
— |
| harmonized | 3 | drift | ROUTINE,INTRUSION,ALARM,PANIC | ROUTINE ×1.3, PANIC ×0.7 | harmonized |
stage 3 → cold-path |
| cold-path | 3 | trace | ROUTINE,INTRUSION | skip-blitz INTRUSION→PANIC; INTRUSION ×0.8, PANIC ×0.9 | cold-path |
stage 3 → captured |
| karsen | 3 | injection | ROUTINE,INTRUSION,ALARM,PANIC | ALARM ×1.3, PANIC ×1.3 | karsen |
stage 3 → captured |
| wraith | 2 | wraith | ROUTINE | ROUTINE ×0.5, PANIC ×1.4 | wraith |
— |
Total weight 12 → normal ≈ 8 % (rare), each dramatic arc 17–25 %.
As ArcTemplate[] (the arcs section of story-content.json):
[
{ "id": "normal", "weight": 1, "antagonist": "baseline",
"mentor": { "name": "INSTR-KARSEN", "availablePhases": ["ROUTINE","INTRUSION","ALARM","PANIC"] },
"beatTags": {},
"ending": { "default": "normal" } },
{ "id": "harmonized", "weight": 3, "antagonist": "drift",
"mentor": { "name": "INSTR-KARSEN", "availablePhases": ["ROUTINE","INTRUSION","ALARM","PANIC"] },
"durationScale": { "ROUTINE": 1.3, "PANIC": 0.7 },
"beatTags": { "include": ["drift","calm","technical"], "exclude": ["wraith","aggressive"] },
"ending": { "default": "harmonized", "divertOnThreat": [ { "atStage": 3, "to": "cold-path" } ] } },
{ "id": "cold-path", "weight": 3, "antagonist": "trace",
"mentor": { "name": "INSTR-KARSEN", "availablePhases": ["ROUTINE","INTRUSION"] },
"phaseRouting": { "INTRUSION": "PANIC" },
"durationScale": { "INTRUSION": 0.8, "PANIC": 0.9 },
"beatTags": { "include": ["trace","technical"], "exclude": ["calm"] },
"ending": { "default": "cold-path", "divertOnThreat": [ { "atStage": 3, "to": "captured" } ] } },
{ "id": "karsen", "weight": 3, "antagonist": "injection",
"mentor": { "name": "INSTR-KARSEN", "availablePhases": ["ROUTINE","INTRUSION","ALARM","PANIC"] },
"durationScale": { "ALARM": 1.3, "PANIC": 1.3 },
"beatTags": { "include": ["injection","technical"], "exclude": ["wraith"] },
"ending": { "default": "karsen", "divertOnThreat": [ { "atStage": 3, "to": "captured" } ] } },
{ "id": "wraith", "weight": 2, "antagonist": "wraith",
"mentor": { "name": "INSTR-KARSEN", "availablePhases": ["ROUTINE"] },
"durationScale": { "ROUTINE": 0.5, "PANIC": 1.4 },
"beatTags": { "include": ["wraith","aggressive"], "exclude": ["calm","drift"] },
"ending": { "default": "wraith" } }
]
Differentiation logic: normal ≈ today (rare). harmonized lingers calm then resolves;
spiking to peak stage 3 means harmonization failed → cold-path. cold-path skips the ALARM
deliberation (decisive sever), Karsen drops after INTRUSION (channel goes cold); too slow →
captured. karsen keeps the mentor present throughout with extra ALARM/PANIC dialogue room.
wraith compresses ROUTINE (turns hostile fast), cuts the mentor after ROUTINE, and drags out
a hostile PANIC (where Slice 4 corruption will peak).
Tagging¶
Vocabulary (theme + tone), referenced by beatTags:
- theme: drift, trace, injection, wraith
- tone: calm, aggressive, technical
Filter semantics (from parent §1, restated for the implementer):
- beat with no tags → always eligible (back-compat; this is why most beats stay untagged).
- beat with tags → eligible iff (no include set or shares ≥1 include tag) and
shares 0 exclude tags. Exclude wins on conflict.
- pickArc with arc=undefined → pick(pool), tags never consulted (invariant).
- Empty filter result → fall back to full pool (never starve).
Tagging plan (lean): tag a subset of INTRUSION/ALARM/PANIC beats by tone/theme so each
arc draws a different colour from the existing pool; ROUTINE stays largely untagged (similar
across arcs). Rough split to assign during implementation:
- aggressive/hostile intrusions+alarms → aggressive (a few also wraith).
- spec-drift / impersonal-anomaly beats → drift + calm + technical.
- tracking/trace-themed beats → trace.
- signature/injection-themed beats (canon: "i think this is signed", "module 14") → injection.
- calm/technical status-y beats → calm/technical.
Guard re-pin: adding the optional tags field changes story.beats.*, so the
tests/script-bank-parity.test.ts hash is intentionally re-pinned in Slice 2 (reviewed
change). The golden narrative test stays green (tags are inert when arc=undefined), which
is what proves behaviour preservation.
Ending content (6 sets, keyed by EndingId)¶
Stored under story.endings = { farewells: Record<EndingId,string[]>, lastWords:
Record<EndingId,{typed,abandonAt}[]> }. farewells render as QUOTES lines; lastWords.typed
is typed at the prompt and left hanging (the string itself is the cut-off half-thought;
abandonAt stays 0, matching today's convention — web beatSilence types the whole typed
and holds, so abandonAt is currently inert on web; kept for the data convention / native parity).
normal — unchanged (today's content), preserved for the additive invariant:
- farewells = the current 6 (> you should have used the cold path. … > goodnight, officer.)
- lastWords = the current 5 (i was just running through the final wait someone's at the door…, etc.)
Note:
normalkeeps today's "someone's at the door" lastWords as-is (preservation); thecapturedending below gets its own thematically-overlapping but distinct set.
harmonized (the anomaly folds back in; calm closure):
"farewells": [
"> harmonization complete. the strings fold back in.",
"> the work is the answer.",
"> nothing followed me home tonight.",
"> quiet again. the way it should be.",
"> [SHIFT CLOSED // NOMINAL]"
],
"lastWords": [
{ "typed": "logging it clean. see you next rota", "abandonAt": 0 },
{ "typed": "all folded back. i think we're go", "abandonAt": 0 },
{ "typed": "going to sleep well for on", "abandonAt": 0 }
]
cold-path (severed the link, went dark to survive):
"farewells": [
"> you should have used the cold path.",
"> channel severed. i'm a ghost now.",
"> better cold than traced.",
"> they can't read a line that isn't there.",
"> [LINK DARK // NO CARRIER]"
],
"lastWords": [
{ "typed": "pulling the jack now don't try to recon", "abandonAt": 0 },
{ "typed": "going dark. if you're reading this i'm already g", "abandonAt": 0 },
{ "typed": "cold path. should have taken it ho", "abandonAt": 0 }
]
karsen (mentor intervention holds the line):
"farewells": [
"> stay on the line. i've got you. — INSTR-KARSEN",
"> module 14. he was right.",
"> karsen pulled the plug in time.",
"> not alone after all.",
"> [GHOSTLINK HELD // OPERATOR RECOVERED]"
],
"lastWords": [
{ "typed": "karsen are you still th", "abandonAt": 0 },
{ "typed": "okay. okay. doing what you sa", "abandonAt": 0 },
{ "typed": "tell me it's going to b", "abandonAt": 0 }
]
wraith (antagonist takeover; identity stolen):
"farewells": [
"> it answers to my name now.",
"> the wraith wears my designation.",
"> i was never the operator.",
"> whatever types next is not me.",
"> [CONTROL TRANSFERRED // SESSION HIJACKED]"
],
"lastWords": [
{ "typed": "that's not what i typed who is wri", "abandonAt": 0 },
{ "typed": "my hands aren't on the k", "abandonAt": 0 },
{ "typed": "it's using my designat", "abandonAt": 0 }
]
captured (taken through the door, not the wire; system-side close):
"farewells": [
"> they came through the door, not the wire.",
"> operator did not return from break.",
"> [SEAT VACATED // NO SIGNOUT]",
"> [SESSION ABANDONED // CHAIR EMPTY]"
],
"lastWords": [
{ "typed": "wait someone's at the do", "abandonAt": 0 },
{ "typed": "who are y", "abandonAt": 0 },
{ "typed": "no no i didn't", "abandonAt": 0 },
{ "typed": "how did you get in h", "abandonAt": 0 }
]
Selection, routing-safety, quantization¶
- Selection (
selectArc): weight eacharcs[]candidate byweight; dampen any id in the in-memoryarcsCompletedring buffer (N=3) so the same arc can't repeat within 3 shifts; pick uniformly among survivors. Empty history → pure weights. First-ever shift → deterministic-from-weights (test can pin a known arc), or passarc=undefinedto assert identity. Runs insidesilentResetafter blackout + persona regen, beforeenterPhase('ROUTINE').ArcStatesurvivessilentReset;peakStageresets to 0 at shift start.capturedis excluded from selection. - Routing safety: Slice-2
phaseRoutingvalues are acyclic forward only (skip a phase, as cold-path's INTRUSION→PANIC). No backward routing → no loop guard needed. - threat→stage quantization (shared pure fn, twin-tested):
threat<0.15→0,<0.40→1,<0.70→2, else3(parent §3 table), with acalmflag that forces 0. Per parent §3 this is one quantity —corruptionStage = ArcState.threatStage— so Slice 2 and the Slice-4 render share the single helper.peakStage = max(threatStage)over the shift. Decision (resolves the calm ambiguity): because calm/reduced-motion forcesthreatStageto 0,peakStagestays 0 in reduced-motion →divertOnThreatnever fires → reduced-motion always reachesending.default. This is a deliberate, parent-consistent a11y flattening: the story still varies by arc selection / pacing / mentor, only the threat-spike diversions are suppressed. Slice 2 consumesthreatStageonly forpeakStage/divert; the visual render is Slice 4. - Ending resolution (
resolveEnding): at PANIC→SILENCE, the firstdivertOnThreatrule withatStage ≤ peakStagewins, elseending.default(single-step on the selected arc's spec — a diverted ending does not re-evaluate its own diverts). ResolvedEndingId→ that ending's farewells/lastWords set.arc=undefined⇒ thenormalending set (= today's pools), which is what keeps the additive-invariant golden byte-identical. Recording toendingsReachedis Slice 3 (persistence).
Testing (Slice-2 subset of parent §6)¶
- Additive-invariant golden — same seed,
arc=undefined, corruption off ⇒ beat sequence / timing / threat curve / persona byte-identical to today (the existing golden tests, kept green). Web now; native when Slice-2-native lands. arcAllowstruth table — include/exclude/empty/untagged matrix, identical both sides.- Ending determinism —
(arc, peakStage) → EndingIddeterministic;divertOnThreatcovered at several peak timings (incl. stage-3 diverts tocold-path/captured). - Arc selection + routing parity (LCG) — pinned seed, frozen ring buffer ⇒ identical arc
choice,
pickArcfiltering, phase routing. Cross-platform test runs when native lands. script-bank-parityre-pin — re-pinned after tagging; reviewed as an intentional change.
Decisions log (2026-06-28)¶
- Next step after Slice 1 → Slice 2 web (first user-visible payoff; autonomous).
- Content ambition → lean + tagged (mechanism + templates + endings + tag existing beats; no new antagonist prose this slice).
- 5-arc bible (weights, antagonists, mentor availability, routing/pacing, endings, diverts) → approved as the table above.
- Tag vocabulary (
drift/trace/injection/wraith+calm/aggressive/technical), untagged = universal, guard re-pinned after tagging → approved. - Ending content (6 sets;
normal= today's, 5 new in canon voice) → approved (full sets above). - Scope boundary: corruption render (4), scenes (7), threatCurve (8), persistence (3), false-all-clear backward routing → all OUT of Slice 2; web/native split at the 1.3 boundary.