diff --git a/src/components/automations/AutomationsSegment.tsx b/src/components/automations/AutomationsSegment.tsx
index 4b487c0..ed7cbac 100644
--- a/src/components/automations/AutomationsSegment.tsx
+++ b/src/components/automations/AutomationsSegment.tsx
@@ -41,13 +41,11 @@ export function AutomationsSegment({ data }: { data: unknown }) {
view.automations.map((a) => )
)}
+ {/* The desktop's always-on system floor (bsc-deny / bsc-confine / bsc-scope) is NOT on
+ the wire — it is a module constant the projector never reads (#239). The note that
+ used to sit here was gated on a `builtin` flag that cannot exist, so it never
+ rendered. It returns when the payload grows a `systemHooks` field. */}
Hooks
- {view.hasSystemFloor && (
-
- Built-in hooks are the desktop's system floor — always on, under every
- permission posture.
-
- )}
{view.hooks.length === 0 ? (
No hooks configured on the desktop yet.
) : (
@@ -69,9 +67,9 @@ function AutomationCard({ automation: a }: { automation: AutomationVM }) {
+ {/* Schedule only — the dispatch target is deliberately withheld desktop-side (#239). */}
{a.whenLabel}
- {a.targetLabel ? ` → ${a.targetLabel}` : ''}
@@ -110,7 +108,6 @@ function HookRow({ hook: h }: { hook: HookVM }) {
{h.name}
- {h.builtin && built-in}
{h.event}
@@ -128,7 +125,6 @@ function HookRow({ hook: h }: { hook: HookVM }) {
const styles = StyleSheet.create({
list: { padding: 16, gap: 10, paddingBottom: 28 },
sectionGap: { marginTop: 14 },
- floorNote: { fontSize: 11.5, lineHeight: 16 },
card: { padding: 14, gap: 6 },
titleLine: { flexDirection: 'row', alignItems: 'center', gap: 8 },
diff --git a/src/lib/mirror/automationsView.test.ts b/src/lib/mirror/automationsView.test.ts
index f86804a..87dd30e 100644
--- a/src/lib/mirror/automationsView.test.ts
+++ b/src/lib/mirror/automationsView.test.ts
@@ -64,40 +64,31 @@ describe('selectAutomationsView', () => {
assert.equal(v.automations[0].runs[0].status, 'unknown');
});
- it('shows the target pane only when the payload carries it', () => {
- const v = selectAutomationsView(payload);
- assert.equal(v.automations[0].targetLabel, null); // today's projection omits it
- const withTarget = selectAutomationsView({
- automations: [{ id: 'a', targetTab: 'Build', targetPaneIdx: 1 }],
- hooks: [],
- });
- assert.equal(withTarget.automations[0].targetLabel, 'Build · pane 2');
- const tabOnly = selectAutomationsView({
- automations: [{ id: 'a', targetTab: 'Build' }],
- hooks: [],
- });
- assert.equal(tabOnly.automations[0].targetLabel, 'Build');
- });
-
it('maps hooks with matcher and scope', () => {
const v = selectAutomationsView(payload);
assert.equal(v.hooks.length, 2);
assert.deepEqual(v.hooks[0], {
id: 'h1', name: 'Lint gate', enabled: true, event: 'PreToolUse',
- matcher: 'Bash', scopeLabel: 'Global', builtin: false,
+ matcher: 'Bash', scopeLabel: 'Global',
});
assert.equal(v.hooks[1].matcher, null);
assert.equal(v.hooks[1].scopeLabel, '2 projects');
- assert.equal(v.hasSystemFloor, false);
});
- it('flags the system floor when the payload marks built-in hooks', () => {
+ /**
+ * #239: the VM must not regrow a field the desktop has ruled out. `targetTab` /
+ * `targetPaneIdx` are withheld with a regression test pinning their absence, and a hook
+ * `builtin` flag cannot exist — `Hook` is exclusively user-authored config. Reading either
+ * produced UI that could never render, which is how the dead system-floor note survived.
+ */
+ it('ignores fields the desktop deliberately never sends', () => {
const v = selectAutomationsView({
- automations: [],
- hooks: [{ id: 'floor', name: 'bsc-deny', enabled: true, event: 'PreToolUse', projects: [], builtin: true }],
+ automations: [{ id: 'a', targetTab: 'Build', targetPaneIdx: 1 }],
+ hooks: [{ id: 'h', name: 'x', enabled: true, event: 'PreToolUse', projects: [], builtin: true }],
});
- assert.equal(v.hasSystemFloor, true);
- assert.equal(v.hooks[0].builtin, true);
+ assert.equal('targetLabel' in v.automations[0], false);
+ assert.equal('builtin' in v.hooks[0], false);
+ assert.equal('hasSystemFloor' in v, false);
});
it('tolerates missing/partial fields', () => {
diff --git a/src/lib/mirror/automationsView.ts b/src/lib/mirror/automationsView.ts
index 7f17378..d4d3f73 100644
--- a/src/lib/mirror/automationsView.ts
+++ b/src/lib/mirror/automationsView.ts
@@ -6,9 +6,21 @@
* AutomationCard = { id, name, armed, when, lastRunAt, nextRunAt, runs }
* HookCard = { id, name, enabled, event, matcher?, projects }
*
- * The projection does not (yet) carry the automation's target pane or a
- * built-in marker on hooks; both are read tolerantly so they light up the
- * moment the desktop starts publishing them, and render as absent until then.
+ * Two fields this file used to read speculatively are GONE (#239). Neither was
+ * "not yet published" — the desktop has ruled both out, with regression tests
+ * pinning their absence:
+ *
+ * - `targetTab` / `targetPaneIdx`: withheld on purpose. `storeProjections.test.ts`
+ * asserts the serialized payload does not contain "targetTab" — the card is
+ * schedule + outcome, the dispatch target stays desktop-side.
+ * - `builtin` on a hook: cannot exist. `Hook` is exclusively user-authored
+ * config, and `HookCard` field-exactness is asserted desktop-side.
+ *
+ * The desktop's always-on system floor (`SYSTEM_HOOKS` — bsc-deny, bsc-confine,
+ * bsc-scope) is a module constant the projector never reads, so it crosses no
+ * frame at all. Surfacing it needs a new `systemHooks` field on the payload;
+ * until that lands there is nothing here to render, and pretending otherwise
+ * showed the user an empty hook list implying nothing is enforced.
*/
import {
@@ -30,8 +42,6 @@ export type AutomationVM = {
armed: boolean;
/** Human cadence label derived from the `when` union ("every day at 09:00"). */
whenLabel: string;
- /** "Tab · pane N" when the payload carries the target; null until it does. */
- targetLabel: string | null;
lastRunAt: number | null;
nextRunAt: number | null;
/** Newest first, capped to the projection's 10. */
@@ -45,20 +55,14 @@ export type HookVM = {
event: string;
matcher: string | null;
scopeLabel: string;
- /** Marked by the payload as part of the desktop's always-on system floor. */
- builtin: boolean;
};
export type AutomationsView = {
automations: AutomationVM[];
hooks: HookVM[];
- /** Any hook is marked built-in → show the system-floor note row. */
- hasSystemFloor: boolean;
};
-export const EMPTY_AUTOMATIONS_VIEW: AutomationsView = {
- automations: [], hooks: [], hasSystemFloor: false,
-};
+export const EMPTY_AUTOMATIONS_VIEW: AutomationsView = { automations: [], hooks: [] };
/** Runs shown per schedule card (mirrors the desktop projection cap). */
export const RUNS_SHOWN = 10;
@@ -94,13 +98,6 @@ function toAutomation(raw: unknown, index: number): AutomationVM | null {
if (!r) return null;
const id = readString(r.id, '') || `automation-${index}`;
- // Target pane — not in today's projection; render when the desktop adds it.
- const targetTab = readString(r.targetTab, '').trim();
- const paneIdx = readNumOrNull(r.targetPaneIdx);
- const targetLabel = targetTab
- ? paneIdx !== null ? `${targetTab} · pane ${paneIdx + 1}` : targetTab
- : null;
-
const runs = asArray(r.runs)
.map(toRun)
.filter((run): run is AutomationRunVM => run !== null)
@@ -112,7 +109,6 @@ function toAutomation(raw: unknown, index: number): AutomationVM | null {
name: readString(r.name, '').trim() || id,
armed: readBool(r.armed, false),
whenLabel: formatWhen(r.when),
- targetLabel,
lastRunAt: readNumOrNull(r.lastRunAt),
nextRunAt: readNumOrNull(r.nextRunAt),
runs,
@@ -130,7 +126,6 @@ function toHook(raw: unknown, index: number): HookVM | null {
event: readString(r.event, '').trim() || '—',
matcher: readString(r.matcher, '').trim() || null,
scopeLabel: scopeLabel(r.projects),
- builtin: readBool(r.builtin, false),
};
}
@@ -144,5 +139,5 @@ export function selectAutomationsView(data: unknown): AutomationsView {
const hooks = asArray(root.hooks)
.map(toHook)
.filter((h): h is HookVM => h !== null);
- return { automations, hooks, hasSystemFloor: hooks.some((h) => h.builtin) };
+ return { automations, hooks };
}
diff --git a/src/lib/tunnel/storePayloads.fixtures.test.ts b/src/lib/tunnel/storePayloads.fixtures.test.ts
index 08f0de7..0e517f8 100644
--- a/src/lib/tunnel/storePayloads.fixtures.test.ts
+++ b/src/lib/tunnel/storePayloads.fixtures.test.ts
@@ -41,6 +41,7 @@ import { selectGlance, glanceL0Input } from '../pages/glancePage';
import { selectOrg, teamToOrgInput } from '../pages/orgPage';
import { selectThemes, groupThemes, OTHER_THEME_GROUP } from '../pages/designPage';
import { selectSkills } from '../pages/skillsPage';
+import { selectAutomationsView } from '../mirror/automationsView';
import { buildGlanceScene, buildOrgScene } from '../graph';
import { selectSecurityView } from '../mirror/securityView';
import { parseAlertsPayload, alertTarget } from '../alerts/model';
@@ -80,7 +81,6 @@ const PENDING_DOMAINS: Record = {
// through verbatim. The fixture therefore shows a pared-looking kit the real wire would not
// produce. Catching pass-through bloat needs the fixture INPUT to carry the optional fields.
components: '#241 — C1/C3/C4 desktop-side; fixture input does not exercise kit pass-through',
- automations: '#239 — system hook floor never projected; two dead reads',
mcp: '#240 — built-in servers invisible; version read is unfixable',
};
@@ -343,6 +343,43 @@ function decodeSkills(o: Raw): Raw {
return out;
}
+/**
+ * `automations` — schedule + outcome only. The dispatch target (`targetTab` / `targetPaneIdx`)
+ * is withheld desktop-side with a regression test pinning its absence, and `HookCard` is
+ * field-exact with no `builtin`: `Hook` is exclusively user-authored config. #239 deleted the
+ * mobile reads that waited on both.
+ *
+ * `runs[].at` is epoch ms on both sides — the ISO-string trap that broke `security` does not
+ * occur here. The desktop caps `runs` at 10 (`AUTOMATION_RUNS_CAP`), asserted desktop-side.
+ *
+ * NOT covered: the always-on system floor (`SYSTEM_HOOKS` — bsc-deny / bsc-confine / bsc-scope)
+ * is a module constant the projector never reads, so it crosses no frame and no decoder can
+ * see it. Needs a `systemHooks` field on the payload.
+ */
+function decodeAutomations(o: Raw): Raw {
+ return {
+ automations: arr(o, 'automations').map((a) => {
+ const out: Raw = {
+ id: str(a, 'id'), name: str(a, 'name'), armed: bool(a, 'armed'),
+ when: a.when as Raw,
+ lastRunAt: num(a, 'lastRunAt'), nextRunAt: num(a, 'nextRunAt'),
+ runs: arr(a, 'runs').map((r) => ({
+ at: num(r, 'at'), status: str(r, 'status'), note: str(r, 'note'),
+ })),
+ };
+ return out;
+ }),
+ hooks: arr(o, 'hooks').map((h) => {
+ const out: Raw = {
+ id: str(h, 'id'), name: str(h, 'name'), enabled: bool(h, 'enabled'),
+ event: str(h, 'event'), projects: strArr(h, 'projects'),
+ };
+ copyOptStr(h, out, 'matcher');
+ return out;
+ }),
+ };
+}
+
const DECODERS: Record Raw> = {
glance: decodeGlance,
security: decodeSecurity,
@@ -350,6 +387,7 @@ const DECODERS: Record Raw> = {
org: decodeOrg,
themes: decodeThemes,
skills: decodeSkills,
+ automations: decodeAutomations,
};
// ── Coverage + vocabulary guards ────────────────────────────────────────────────────────────
@@ -588,6 +626,33 @@ test('Layer B: skills — the no-lessons variant parses as null, not as an empty
assert.equal(model.skills.length, 1, 'the library must survive a null lessons block');
});
+test('Layer B: automations — the schedule card renders from real values', () => {
+ const view = selectAutomationsView(fx.domains.automations);
+ assert.equal(view.automations.length, 1, 'the canonical automation did not survive the parse');
+
+ const a = view.automations[0];
+ assert.equal(a.name, 'Nightly triage');
+ assert.notEqual(a.name, a.id, 'the name fell back to the id');
+ assert.equal(a.armed, true);
+ assert.equal(a.whenLabel, 'every day at 02:00', 'the `when` union must format, not fall back to —');
+ assert.notEqual(a.whenLabel, '—');
+ assert.equal(a.lastRunAt, 200);
+ assert.equal(a.nextRunAt, 300);
+
+ // Runs: newest first, statuses mapped — `unknown` is the fallback, so it must not appear.
+ assert.deepEqual(a.runs.map((r) => r.at), [200, 100]);
+ assert.equal(a.runs[0].status, 'ok');
+ assert.equal(a.runs[1].status, 'skipped');
+ assert.equal(a.runs[0].note, 'triaged 4 issues');
+ for (const r of a.runs) assert.notEqual(r.status, 'unknown', 'a run status fell back');
+
+ const h = view.hooks[0];
+ assert.equal(h.name, 'deny-floor');
+ assert.equal(h.enabled, true);
+ assert.equal(h.event, 'PreToolUse');
+ assert.notEqual(h.event, '—');
+});
+
// ── The invariant that gives Layer B its teeth ───────────────────────────────────────────────
test('the fixture states the no-fallback-values invariant', () => {