instar 1.3.1169 → 1.3.1171

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3541,8 +3541,8 @@
3541
3541
  What the internal AI calls cost, and where the paid-door caps sit. The dollar figures are a
3542
3542
  <b>reporting</b> view — immutable token counts priced against a reviewed price list on read, so a
3543
3543
  price correction reflows automatically. <b>No paid door is live yet</b>, so metered spend is $0;
3544
- subscription/CLI doors are <b>$0 (not per-token billed)</b>. This is read-only — money caps and the
3545
- go-live control are a later increment.
3544
+ subscription/CLI doors are <b>$0 (not per-token billed)</b>. The spend figures above are a report;
3545
+ the controls below set a door's ceilings and take it live, and each of those takes your PIN.
3546
3546
  </div>
3547
3547
 
3548
3548
  <!-- Glance floors F10/F11 (topic 29836, Phase 3): the shared component renders
@@ -3550,6 +3550,12 @@
3550
3550
  became plain words at the glance ("pay-per-use"); the per-model math and the
3551
3551
  paid-door caps moved one drill down. -->
3552
3552
  <div id="spendGlance" class="glance-root"></div>
3553
+
3554
+ <!-- Paid-door arming (Increment B UI). Two PIN-committed steps: set both ceilings,
3555
+ then take the door live. The server renders the exact wording of each step and
3556
+ the commit sends back only the plan id + nonce, so a field the operator never
3557
+ saw rendered cannot land. Freeze needs no PIN — halting money is always cheap. -->
3558
+ <div id="spendArming" class="spend-arm-root"></div>
3553
3559
  </div>
3554
3560
 
3555
3561
  <!-- Evidence Tab (WikiClaim Phase 4) — per-entity evidence + reverse "what cites this?" view.
@@ -3634,6 +3640,33 @@
3634
3640
 
3635
3641
  <!-- subscriptions tab (Subscription & Auth Standard P2.2 — live quota + pending logins) -->
3636
3642
  <style>
3643
+ /* Paid-door arming controls (Spend tab). Mobile-first: full-width stacked fields
3644
+ and 44px touch targets, since the operator reaches this from their phone. */
3645
+ .spend-arm-root { display:flex; flex-direction:column; gap:12px; margin-top:8px;
3646
+ border-top:1px solid var(--border,#2a2a2a); padding-top:16px; }
3647
+ .spend-arm-h { margin:0; font-size:16px; }
3648
+ .spend-arm-field { display:flex; flex-direction:column; gap:6px; font-size:13px; opacity:.9; }
3649
+ .spend-arm-input { width:100%; box-sizing:border-box; min-height:44px; padding:10px 12px;
3650
+ font-size:16px; /* 16px stops iOS zooming the page on focus */
3651
+ border:1px solid var(--border,#2a2a2a); border-radius:8px; background:transparent; color:inherit; }
3652
+ .spend-arm-hint { font-size:12px; opacity:.6; }
3653
+ .spend-arm-btns { display:flex; flex-wrap:wrap; gap:8px; }
3654
+ .spend-arm-btn { min-height:44px; padding:10px 14px; font-size:14px; border-radius:8px;
3655
+ border:1px solid var(--border,#2a2a2a); background:transparent; color:inherit; cursor:pointer; flex:1 1 auto; }
3656
+ .spend-arm-btn-freeze { border-color:#b45309; color:#f59e0b; }
3657
+ .spend-arm-btn-commit { border-color:#16a34a; color:#22c55e; width:100%; margin-top:10px; }
3658
+ .spend-arm-plan { display:flex; flex-direction:column; gap:8px; }
3659
+ .spend-arm-plan-label { font-size:12px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; opacity:.6; }
3660
+ .spend-arm-plan-text { font-size:14px; line-height:1.5; padding:12px; border-radius:8px;
3661
+ border:1px solid var(--border,#2a2a2a); white-space:pre-wrap; word-break:break-word; }
3662
+ .spend-arm-plan-expiry, .spend-arm-note { font-size:12px; opacity:.7; }
3663
+ @media (max-width:520px) { .spend-arm-btn { flex:1 1 100%; } }
3664
+
3665
+ /* Provider group heading — only rendered when more than one provider is
3666
+ enrolled, so a single-provider install looks exactly as it did before. */
3667
+ .sub-provider-heading { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
3668
+ opacity:.6; margin:14px 0 8px; padding-bottom:6px; border-bottom:1px solid var(--border,#2a2a2a); }
3669
+ .sub-provider-heading:first-child { margin-top:0; }
3637
3670
  .sub-account { border:1px solid var(--border,#2a2a2a); border-radius:10px; padding:14px 16px; margin-bottom:12px; }
3638
3671
  .sub-account-inuse { border-color:#16a34a; box-shadow:0 0 0 1px #16a34a44; }
3639
3672
  .sub-account-inuse-badge { font-size:11px; font-weight:600; color:#22c55e; margin-right:auto; }
@@ -6005,6 +6038,10 @@
6005
6038
 
6006
6039
  const spec = glance.spendGlanceSpec(document, summary, caps);
6007
6040
  glance.renderGlance(document, glanceRoot, spec);
6041
+ // The arming controls sit below the read-only glance. Rendered from the SAME caps
6042
+ // payload, so the door list and its live/frozen wording cannot drift from the view
6043
+ // above it.
6044
+ renderSpendArming(caps);
6008
6045
  }
6009
6046
 
6010
6047
  async function loadResources() {
@@ -8630,6 +8667,217 @@
8630
8667
  return __glanceModule;
8631
8668
  }
8632
8669
 
8670
+ // ── Spend tab — paid-door arming (Increment B UI) ──
8671
+ // Two PIN-committed steps (set both ceilings, then go live), each previewed as the
8672
+ // SERVER's rendered wording before the PIN box appears. The commit posts only the
8673
+ // plan id + nonce + PIN, never the form fields.
8674
+ let __spendArmModule = null;
8675
+ async function loadSpendArmModule() {
8676
+ if (!__spendArmModule) {
8677
+ try { __spendArmModule = await import('/dashboard/spend-arming.js'); }
8678
+ catch (e) { console.error('[spend-arming] module load failed', e); return null; }
8679
+ }
8680
+ return __spendArmModule;
8681
+ }
8682
+
8683
+ // The plan currently awaiting approval. Cleared whenever inputs change, so a PIN can
8684
+ // never be applied to a plan the operator has since edited away from.
8685
+ let __spendPendingPlan = null;
8686
+
8687
+ async function renderSpendArming(caps) {
8688
+ const root = document.getElementById('spendArming');
8689
+ if (!root) return;
8690
+ const M = await loadSpendArmModule();
8691
+ if (!M) { root.textContent = 'Loading the money controls failed — refresh to retry.'; return; }
8692
+ root.replaceChildren();
8693
+
8694
+ const h = document.createElement('h3');
8695
+ h.textContent = 'Paid doors';
8696
+ h.className = 'spend-arm-h';
8697
+ root.appendChild(h);
8698
+
8699
+ const rows = M.armableDoors(caps);
8700
+ if (rows.length === 0) {
8701
+ const n = document.createElement('div');
8702
+ n.className = 'spend-arm-note';
8703
+ n.textContent = 'No pay-per-use doors are configured on this agent.';
8704
+ root.appendChild(n);
8705
+ return;
8706
+ }
8707
+
8708
+ const sel = document.createElement('select');
8709
+ sel.id = 'spendArmKey';
8710
+ sel.className = 'spend-arm-input';
8711
+ for (const r of rows) {
8712
+ const o = document.createElement('option');
8713
+ o.value = r.keyRef;
8714
+ // textContent — keyRef/provider are server data, never markup.
8715
+ o.textContent = `${r.provider} · ${r.door} — ${M.doorStateWords(r)}`;
8716
+ sel.appendChild(o);
8717
+ }
8718
+ const selWrap = document.createElement('label');
8719
+ selWrap.className = 'spend-arm-field';
8720
+ selWrap.appendChild(document.createTextNode('Which door'));
8721
+ selWrap.appendChild(sel);
8722
+ root.appendChild(selWrap);
8723
+
8724
+ const mkNum = (id, label, hint) => {
8725
+ const w = document.createElement('label');
8726
+ w.className = 'spend-arm-field';
8727
+ w.appendChild(document.createTextNode(label));
8728
+ const i = document.createElement('input');
8729
+ i.id = id; i.type = 'number'; i.min = '0'; i.step = '0.01';
8730
+ i.className = 'spend-arm-input'; i.inputMode = 'decimal';
8731
+ i.addEventListener('input', () => { __spendPendingPlan = null; renderSpendPlan(null); });
8732
+ w.appendChild(i);
8733
+ const hn = document.createElement('div');
8734
+ hn.className = 'spend-arm-hint'; hn.textContent = hint;
8735
+ w.appendChild(hn);
8736
+ return w;
8737
+ };
8738
+ root.appendChild(mkNum('spendArmLifetime', 'Lifetime ceiling (USD)',
8739
+ 'The total this door may ever spend. It stops permanently here.'));
8740
+ root.appendChild(mkNum('spendArmDaily', 'Daily ceiling (USD)',
8741
+ 'The most it may spend in one day. Both ceilings are required.'));
8742
+ sel.addEventListener('change', () => { __spendPendingPlan = null; renderSpendPlan(null); });
8743
+
8744
+ const btns = document.createElement('div');
8745
+ btns.className = 'spend-arm-btns';
8746
+ const mkBtn = (text, cls, fn) => {
8747
+ const b = document.createElement('button');
8748
+ b.textContent = text; b.className = cls; b.onclick = fn; return b;
8749
+ };
8750
+ btns.appendChild(mkBtn('Preview cap change', 'spend-arm-btn', () => previewSpendPlan('caps-adjust')));
8751
+ btns.appendChild(mkBtn('Preview go live', 'spend-arm-btn', () => previewSpendPlan('go-live')));
8752
+ btns.appendChild(mkBtn('Freeze this door', 'spend-arm-btn spend-arm-btn-freeze', freezeSpendDoor));
8753
+ root.appendChild(btns);
8754
+
8755
+ const planBox = document.createElement('div');
8756
+ planBox.id = 'spendArmPlan'; planBox.className = 'spend-arm-plan';
8757
+ root.appendChild(planBox);
8758
+
8759
+ const status = document.createElement('div');
8760
+ status.id = 'spendArmStatus'; status.className = 'spend-arm-note';
8761
+ root.appendChild(status);
8762
+ }
8763
+
8764
+ function selectedSpendRow(caps, M) {
8765
+ const key = (document.getElementById('spendArmKey') || {}).value;
8766
+ return M.armableDoors(caps).find((r) => r.keyRef === key) || null;
8767
+ }
8768
+
8769
+ function renderSpendPlan(plan) {
8770
+ const box = document.getElementById('spendArmPlan');
8771
+ if (!box) return;
8772
+ loadSpendArmModule().then((M) => {
8773
+ if (!M) return;
8774
+ M.renderPlanPreview(document, box, plan);
8775
+ if (!plan) return;
8776
+ // The PIN box appears ONLY once there is a rendered plan to approve, so the
8777
+ // operator never types a secret against something they have not read.
8778
+ const w = document.createElement('label');
8779
+ w.className = 'spend-arm-field';
8780
+ w.appendChild(document.createTextNode('Dashboard PIN'));
8781
+ const p = document.createElement('input');
8782
+ p.type = 'password'; p.id = 'spendArmPin'; p.className = 'spend-arm-input';
8783
+ p.autocomplete = 'off'; p.inputMode = 'numeric';
8784
+ w.appendChild(p);
8785
+ box.appendChild(w);
8786
+ const b = document.createElement('button');
8787
+ b.className = 'spend-arm-btn spend-arm-btn-commit';
8788
+ b.textContent = 'Approve with PIN';
8789
+ b.onclick = commitSpendPlan;
8790
+ box.appendChild(b);
8791
+ });
8792
+ }
8793
+
8794
+ function setSpendArmStatus(text) {
8795
+ const s = document.getElementById('spendArmStatus');
8796
+ if (s) s.textContent = text;
8797
+ }
8798
+
8799
+ async function previewSpendPlan(action) {
8800
+ const M = await loadSpendArmModule();
8801
+ if (!M) return;
8802
+ const caps = await apiFetch('/routing-spend/caps').catch(() => null);
8803
+ const row = selectedSpendRow(caps, M);
8804
+ if (!row) { setSpendArmStatus('Pick a door first.'); return; }
8805
+
8806
+ let fields;
8807
+ if (action === 'caps-adjust') {
8808
+ const num = (id) => {
8809
+ const raw = (document.getElementById(id) || {}).value;
8810
+ return raw === '' || raw === undefined ? '' : Number(raw);
8811
+ };
8812
+ const lifetimeCapUsd = num('spendArmLifetime');
8813
+ const dailyCapUsd = num('spendArmDaily');
8814
+ const v = M.validateCaps({ lifetimeCapUsd, dailyCapUsd });
8815
+ if (!v.ok) { setSpendArmStatus(v.error); return; }
8816
+ fields = { keyRef: row.keyRef, provider: row.provider, lifetimeCapUsd, dailyCapUsd };
8817
+ } else {
8818
+ fields = { door: row.door, keyRef: row.keyRef, enabled: true };
8819
+ }
8820
+
8821
+ try {
8822
+ const plan = await apiFetch('/routing-spend/plan', {
8823
+ method: 'POST',
8824
+ headers: { 'Content-Type': 'application/json' },
8825
+ body: JSON.stringify(M.planRequest(action, fields)),
8826
+ });
8827
+ __spendPendingPlan = plan;
8828
+ setSpendArmStatus('');
8829
+ renderSpendPlan(plan);
8830
+ } catch (err) {
8831
+ __spendPendingPlan = null;
8832
+ renderSpendPlan(null);
8833
+ setSpendArmStatus(M.moneyLayerNote(err && err.message));
8834
+ }
8835
+ }
8836
+
8837
+ async function commitSpendPlan() {
8838
+ const M = await loadSpendArmModule();
8839
+ if (!M || !__spendPendingPlan) { setSpendArmStatus('Preview a change first.'); return; }
8840
+ const pinEl = document.getElementById('spendArmPin');
8841
+ const pin = pinEl ? pinEl.value : '';
8842
+ if (!pin) { setSpendArmStatus('Enter your dashboard PIN to approve.'); return; }
8843
+ try {
8844
+ await apiFetch('/routing-spend/caps/adjust', {
8845
+ method: 'POST',
8846
+ headers: { 'Content-Type': 'application/json' },
8847
+ body: JSON.stringify(M.commitRequest(__spendPendingPlan, pin)),
8848
+ });
8849
+ if (pinEl) pinEl.value = '';
8850
+ __spendPendingPlan = null;
8851
+ renderSpendPlan(null);
8852
+ setSpendArmStatus('Applied.');
8853
+ loadRoutingSpend();
8854
+ } catch (err) {
8855
+ if (pinEl) pinEl.value = '';
8856
+ setSpendArmStatus(M.moneyLayerNote(err && err.message));
8857
+ }
8858
+ }
8859
+
8860
+ async function freezeSpendDoor() {
8861
+ const M = await loadSpendArmModule();
8862
+ if (!M) return;
8863
+ const caps = await apiFetch('/routing-spend/caps').catch(() => null);
8864
+ const row = selectedSpendRow(caps, M);
8865
+ if (!row) { setSpendArmStatus('Pick a door first.'); return; }
8866
+ try {
8867
+ // No PIN: halting money is always cheap and always available. UNfreezing is the
8868
+ // PIN-gated direction.
8869
+ await apiFetch('/routing-spend/freeze', {
8870
+ method: 'POST',
8871
+ headers: { 'Content-Type': 'application/json' },
8872
+ body: JSON.stringify({ keyRef: row.keyRef }),
8873
+ });
8874
+ setSpendArmStatus('Frozen. Spending on that door is halted.');
8875
+ loadRoutingSpend();
8876
+ } catch (err) {
8877
+ setSpendArmStatus(M.moneyLayerNote(err && err.message));
8878
+ }
8879
+ }
8880
+
8633
8881
  async function loadCommitments() {
8634
8882
  const glanceRoot = document.getElementById('commitmentsGlance');
8635
8883
  const countBadge = document.getElementById('tabCommitmentCount');
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Spend tab — the paid-door arming controls (Routing Control Room, Increment B UI).
3
+ *
4
+ * The money layer's routes existed but had no screen, while the Spend tab told the
5
+ * operator that caps and go-live were "a later increment". So an operator told the
6
+ * arming was "one tap in the Spend tab, enter your PIN" went looking for a PIN box
7
+ * that had never been built. This is that screen.
8
+ *
9
+ * Three things this module refuses to do, because each one caused or repeated that
10
+ * failure:
11
+ *
12
+ * 1. It never claims a control is available when the server says the money layer is
13
+ * off. A 503 renders the REASON — that enabling it is the operator's own switch,
14
+ * reserved to them by design — not a generic "something went wrong".
15
+ * 2. It never sends the PIN anywhere except the commit call. The plan/preview call
16
+ * is deliberately PIN-free, so the operator sees exactly what they are approving
17
+ * BEFORE any secret leaves the page.
18
+ * 3. It never derives what will be committed from the form. The server renders the
19
+ * plan text; the commit sends back only the plan id and nonce. A field the
20
+ * operator never saw rendered cannot land.
21
+ *
22
+ * Every dynamic value is written with textContent (same contract as subscriptions.js).
23
+ */
24
+
25
+ /** Element helper — textContent ONLY, never innerHTML. */
26
+ function el(doc, tag, cls, text) {
27
+ const n = doc.createElement(tag);
28
+ if (cls) n.className = cls;
29
+ if (text !== undefined && text !== null) n.textContent = String(text);
30
+ return n;
31
+ }
32
+
33
+ /**
34
+ * What to tell the operator when a money-layer call fails.
35
+ *
36
+ * The 503 case is the one that matters. "Money controls are unavailable" would repeat
37
+ * the original confusion — the operator would go looking for what they did wrong. The
38
+ * money layer being off is not a fault and not something they can fix from this screen;
39
+ * it is a switch deliberately reserved to them, and saying so is the whole point.
40
+ */
41
+ export function moneyLayerNote(message) {
42
+ const m = message === undefined || message === null ? '' : String(message);
43
+ if (/503|not enabled|money layer/i.test(m)) {
44
+ return 'Money controls are switched off on this agent. Turning them on is your decision — ' +
45
+ 'it is deliberately not something an agent or a developer can flip. Once it is on, these ' +
46
+ 'controls work; until then nothing here can arm a door.';
47
+ }
48
+ if (/caps store unreadable/i.test(m)) {
49
+ return 'The caps record could not be read, so money controls are refusing to act rather ' +
50
+ 'than guess. Nothing has changed.';
51
+ }
52
+ if (/pin/i.test(m)) {
53
+ return 'That PIN was not accepted. Nothing has changed.';
54
+ }
55
+ return 'Could not reach the money controls just now. Nothing has changed — try again.';
56
+ }
57
+
58
+ /**
59
+ * Validate the two ceilings before asking the server for a plan.
60
+ *
61
+ * BOTH are required: the server takes a lifetime ceiling and a daily ceiling, and there
62
+ * is no monthly figure to fall back on. A monthly intent has to be expressed as both, and
63
+ * leaving one blank silently produces a very different product — a daily rate with no
64
+ * lifetime bound is a tap left running, not a budget.
65
+ */
66
+ export function validateCaps(input) {
67
+ const { lifetimeCapUsd, dailyCapUsd } = input || {};
68
+ for (const [name, label] of [['lifetimeCapUsd', 'lifetime'], ['dailyCapUsd', 'daily']]) {
69
+ const v = (input || {})[name];
70
+ if (v === '' || v === null || v === undefined) {
71
+ return { ok: false, error: `Enter a ${label} ceiling — the server requires both.` };
72
+ }
73
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
74
+ return { ok: false, error: `The ${label} ceiling must be a number.` };
75
+ }
76
+ if (v <= 0) {
77
+ return { ok: false, error: `The ${label} ceiling must be greater than zero.` };
78
+ }
79
+ }
80
+ if (dailyCapUsd > lifetimeCapUsd) {
81
+ return {
82
+ ok: false,
83
+ error: 'The daily ceiling is above the lifetime ceiling, so the daily one could never ' +
84
+ 'bind. Lower the daily figure or raise the lifetime one.',
85
+ };
86
+ }
87
+ return { ok: true };
88
+ }
89
+
90
+ /**
91
+ * The request body for a plan preview. Deliberately a pure function so a test can prove
92
+ * the PIN is NOT in it — a preview that carried the secret would defeat the point of
93
+ * showing the operator what they are approving first.
94
+ */
95
+ export function planRequest(action, fields) {
96
+ const f = fields || {};
97
+ if (action === 'caps-adjust') {
98
+ return {
99
+ action: 'caps-adjust',
100
+ keyRef: f.keyRef,
101
+ provider: f.provider,
102
+ lifetimeCapUsd: f.lifetimeCapUsd,
103
+ dailyCapUsd: f.dailyCapUsd,
104
+ };
105
+ }
106
+ if (action === 'go-live') {
107
+ return { action: 'go-live', door: f.door, keyRef: f.keyRef, enabled: f.enabled === true };
108
+ }
109
+ if (action === 'unfreeze') {
110
+ return { action: 'unfreeze', keyRef: f.keyRef };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * The commit body. Carries ONLY the plan identity and the PIN — never the form fields.
117
+ *
118
+ * This is the load-bearing security shape: the server derives what to apply solely from
119
+ * the plan it rendered, so a value the operator never saw on screen cannot be committed
120
+ * by editing the form after previewing.
121
+ */
122
+ export function commitRequest(plan, pin) {
123
+ return { planId: (plan || {}).planId, nonce: (plan || {}).nonce, pin };
124
+ }
125
+
126
+ /** Render the server's plan text for approval. The text is the server's, shown verbatim. */
127
+ export function renderPlanPreview(doc, target, plan) {
128
+ if (!target) return;
129
+ target.replaceChildren();
130
+ if (!plan || !plan.renderedText) {
131
+ target.appendChild(el(doc, 'div', 'spend-arm-note', 'No plan to approve yet.'));
132
+ return;
133
+ }
134
+ target.appendChild(el(doc, 'div', 'spend-arm-plan-label', 'Approve exactly this:'));
135
+ target.appendChild(el(doc, 'div', 'spend-arm-plan-text', plan.renderedText));
136
+ if (plan.expiresAt) {
137
+ target.appendChild(el(doc, 'div', 'spend-arm-plan-expiry', `This approval expires at ${plan.expiresAt}.`));
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The per-door rows the arming panel offers.
143
+ *
144
+ * Only METERED (paid) doors can be armed, so subscription doors are excluded rather than
145
+ * rendered as inert rows — an unarmable row invites the same "where do I tap?" confusion
146
+ * this screen exists to remove.
147
+ */
148
+ export function armableDoors(caps) {
149
+ const keys = (caps && Array.isArray(caps.keys)) ? caps.keys : [];
150
+ return keys
151
+ .filter((k) => k && typeof k.keyRef === 'string' && k.keyRef.length > 0)
152
+ .map((k) => ({
153
+ keyRef: k.keyRef,
154
+ provider: k.provider,
155
+ door: k.door,
156
+ lifetimeCapUsd: k.lifetimeCapUsd,
157
+ dailyCapUsd: k.dailyCapUsd,
158
+ frozen: k.frozen === true,
159
+ goLiveState: k.goLiveState,
160
+ live: k.goLiveState === 'live',
161
+ }));
162
+ }
163
+
164
+ /** Plain-language state for a door, so the operator can tell armed from not-armed. */
165
+ export function doorStateWords(row) {
166
+ if (!row) return '';
167
+ if (row.frozen) return 'Frozen — spending is halted';
168
+ if (row.live) return 'Live — this door can spend';
169
+ return 'Not live — this door cannot spend';
170
+ }
@@ -195,6 +195,47 @@ export function quotaBar(doc, label, pct, resetIso, now = Date.now()) {
195
195
  return wrap;
196
196
  }
197
197
 
198
+ /**
199
+ * Order accounts so each provider's accounts sit together, interleaving a heading
200
+ * marker before each group. Returns a flat list of accounts with `{__providerHeading}`
201
+ * markers spliced in, so the caller keeps ONE loop and one card-rendering path.
202
+ *
203
+ * Two deliberate choices:
204
+ *
205
+ * - **Groups appear in first-appearance order, and accounts keep their order within a
206
+ * group.** Nothing is sorted. The pool's order carries meaning the dashboard cannot
207
+ * see (enrolment order, which account is the default), so grouping re-associates
208
+ * without re-ranking.
209
+ *
210
+ * - **A single-provider list gets NO heading.** Every instar install with one provider
211
+ * renders exactly as it did before — the heading only appears once there is genuinely
212
+ * something to tell apart, which is the condition that prompted this. That property is
213
+ * pinned by a test, because "we grouped everything" would quietly add a redundant
214
+ * heading to the common case.
215
+ */
216
+ export function groupAccountsByProvider(accounts) {
217
+ if (!Array.isArray(accounts)) return [];
218
+ const groups = new Map(); // provider key (raw) → accounts, in first-appearance order
219
+ for (const a of accounts) {
220
+ const key = a && typeof a.provider === 'string' ? a.provider : '';
221
+ if (!groups.has(key)) groups.set(key, []);
222
+ groups.get(key).push(a);
223
+ }
224
+ const out = [];
225
+ const multi = groups.size > 1;
226
+ for (const [key, members] of groups) {
227
+ if (multi) {
228
+ // friendlyProvider maps anthropic→Claude / openai→Codex and SANITIZES anything
229
+ // else, so an unrecognised or hostile provider string cannot reach the DOM raw.
230
+ // An absent provider has no honest name, so say so rather than invent one.
231
+ const label = friendlyProvider(key) || 'Other';
232
+ out.push({ __providerHeading: label });
233
+ }
234
+ for (const m of members) out.push(m);
235
+ }
236
+ return out;
237
+ }
238
+
198
239
  /** Per-account rows: nickname, status, provider·framework, 5h + weekly quota bars.
199
240
  * `inUseAccountId` (optional) is the account the agent is CURRENTLY running on —
200
241
  * that card gets an "In use" marker so "active" (healthy) reads distinct from
@@ -206,7 +247,15 @@ export function renderAccounts(doc, target, accounts, now = Date.now(), inUseAcc
206
247
  target.appendChild(el(doc, 'div', 'sub-empty', 'No subscription accounts enrolled yet.'));
207
248
  return;
208
249
  }
209
- for (const a of accounts) {
250
+ for (const a of groupAccountsByProvider(accounts)) {
251
+ // A group marker, not an account: render the provider heading and continue.
252
+ // (The operator's complaint was a flat interleaved list — Claude and Codex
253
+ // accounts mixed together with only a small per-card meta line to tell them
254
+ // apart. The heading is what makes the split scannable.)
255
+ if (a && a.__providerHeading) {
256
+ target.appendChild(el(doc, 'div', 'sub-provider-heading', a.__providerHeading));
257
+ continue;
258
+ }
210
259
  const inUse = !!(inUseAccountId && a && a.id === inUseAccountId);
211
260
  const card = el(doc, 'div', inUse ? 'sub-account sub-account-inuse' : 'sub-account');
212
261
  const head = el(doc, 'div', 'sub-account-head');
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1169",
5
+ "packageVersion": "1.3.1171",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "ee1555f5e0d6589d8b1b4fdf24ebe91f6435aca13c9e8f4df5edeebb1338e196",
2
+ "sha256": "878fad6f61a7948606c4c2d116b92656e0449bab6e005987433fc6bcef6309e6",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1169"
4
+ "packageVersion": "1.3.1171"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1169"
5
+ "packageVersion": "1.3.1171"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.1169",
3
+ "version": "1.3.1171",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-08-16T01:19:21.033Z",
5
- "instarVersion": "1.3.1169",
4
+ "generatedAt": "2026-08-16T04:17:05.922Z",
5
+ "instarVersion": "1.3.1171",
6
6
  "entryCount": 202,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1169",
5
+ "packageVersion": "1.3.1171",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "ee1555f5e0d6589d8b1b4fdf24ebe91f6435aca13c9e8f4df5edeebb1338e196",
2
+ "sha256": "878fad6f61a7948606c4c2d116b92656e0449bab6e005987433fc6bcef6309e6",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1169"
4
+ "packageVersion": "1.3.1171"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1169"
5
+ "packageVersion": "1.3.1171"
6
6
  }
@@ -0,0 +1,55 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The Subscriptions tab now groups your accounts by provider — Claude accounts together, Codex
9
+ accounts together, each under a heading.
10
+
11
+ Before, the list was one flat run of cards in enrolment order, so a Codex account could sit
12
+ between two Claude ones. Each card names its provider, but in small text partway down, so
13
+ telling them apart meant reading every card. With a single provider that was fine. With two it
14
+ stopped being fine.
15
+
16
+ The quota bars live inside the account cards, so grouping the accounts groups the quota view
17
+ along with them.
18
+
19
+ ## What to Tell Your User
20
+
21
+ - "Your accounts list is grouped by provider now, so your Codex account no longer sits in the
22
+ middle of your Claude ones."
23
+ - "If you only have one provider, nothing changes at all."
24
+
25
+ ## Summary of New Capabilities
26
+
27
+ None. This is a presentation change to an existing tab — no endpoint, no configuration.
28
+
29
+ ## Evidence
30
+
31
+ 8 tests in the existing jsdom suite, which exercises the shipped dashboard module against a real
32
+ DOM. Shown capable of failing: restoring the flat list fails exactly the four grouping
33
+ assertions while the four invariant tests keep passing.
34
+
35
+ The controls carry the weight — a single-provider list is asserted unchanged, and order within a
36
+ group is asserted preserved. Without them, "grouping works" would pass equally well against a
37
+ change that sorted everything alphabetically and added a redundant heading to every
38
+ single-provider install.
39
+
40
+ The heading is the one new dynamic string reaching the page, so it rides the tab's existing
41
+ sanitize-and-write-as-text contract; a test plants a hostile provider string and confirms no
42
+ live element and no event-handler attribute survives.
43
+
44
+ ## Compatibility Notes
45
+
46
+ **A single-provider install is untouched**: with one provider no heading is drawn, and the list
47
+ renders exactly as before. That is deliberate — a heading reading "Claude" above a list of only
48
+ Claude accounts is noise — and it is pinned by a test.
49
+
50
+ Accounts keep their existing order within each group. Nothing is sorted alphabetically, because
51
+ the order accounts arrive in carries meaning the dashboard cannot see (which was enrolled first,
52
+ which is the default). The change re-associates without re-ranking.
53
+
54
+ An account with no provider is still shown, under a heading reading "Other", rather than being
55
+ dropped or filed under a provider it does not belong to.
@@ -0,0 +1,56 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The Spend tab gains the screen for arming a paid door — setting its spending limits and taking
9
+ it live. The machinery for this existed and worked; the screen did not, and the tab's own small
10
+ print said caps and go-live were "a later increment". So an operator told arming was "one tap,
11
+ enter your PIN" went looking for a PIN box that had never been drawn. That copy is corrected too.
12
+
13
+ ## What to Tell Your User
14
+
15
+ - "You can now set a paid door's limits and turn it on from the Spend tab, on your phone."
16
+ - "It asks for your PIN twice on purpose — once to set the limits, once to start spending."
17
+ - "The freeze button needs no PIN. Stopping money should always be immediate."
18
+
19
+ ## Summary of New Capabilities
20
+
21
+ | Capability | How to Use |
22
+ |-----------|-----------|
23
+ | Set a paid door's ceilings | Spend tab → Paid doors → enter both ceilings → Preview → PIN |
24
+ | Take a paid door live | Spend tab → Paid doors → Preview go live → PIN |
25
+ | Freeze a door immediately | Spend tab → Paid doors → Freeze (no PIN) |
26
+
27
+ ## Evidence
28
+
29
+ 31 unit tests against the shipped module in a real DOM, plus wiring tests asserting that every
30
+ dashboard module the page imports exists on disk — the guard against the "built but unreachable"
31
+ shape this feature is fixing.
32
+
33
+ Shown capable of failing, independently: leaking the PIN into the preview request fails exactly
34
+ the security test; replacing the honest switched-off message with a generic one fails exactly the
35
+ two tests pinning it. A control asserts an ordinary network failure does not claim the money layer
36
+ is off.
37
+
38
+ Not yet proven live: the money layer is off on this agent, so the end-to-end arming path cannot be
39
+ exercised until an operator enables it. That is stated rather than implied away.
40
+
41
+ ## Compatibility Notes
42
+
43
+ **The controls cannot arm anything until the money layer is switched on, and that switch is
44
+ reserved to the operator** — deliberately not something an agent or a developer can flip. Used
45
+ while it is off, the screen says exactly that, rather than showing a generic error that would
46
+ send someone hunting for a mistake they did not make.
47
+
48
+ **Both ceilings are required.** A door has a lifetime ceiling and a daily ceiling, and there is
49
+ no monthly setting, so a monthly intention must be expressed as both. The screen refuses a blank
50
+ rather than choosing one silently: $100 lifetime with $3.30/day is a hard budget that also cannot
51
+ burn in an afternoon, while $3.30/day with a large lifetime is a tap left running.
52
+
53
+ **You approve rendered words, not a button.** The server writes out what it is about to do and
54
+ the screen shows that sentence before the PIN box appears. What commits is that plan, not the
55
+ form — so editing a number after previewing invalidates the approval, and a figure never read
56
+ cannot land.
@@ -0,0 +1,86 @@
1
+ # Side-Effects Review — paid-door arming UI
2
+
3
+ ## Summary
4
+
5
+ The routing-spend money layer's routes (`/routing-spend/plan`, `/caps/adjust`, `/freeze`) had no
6
+ screen, while the Spend tab's own copy said caps and go-live were "a later increment". This adds
7
+ the two-step arming flow and corrects that copy.
8
+
9
+ ## How this arose
10
+
11
+ An operator was told arming was "one tap in the Spend tab, enter your PIN". It was not — the tab
12
+ was read-only and said so. They went looking for a PIN box that had never been drawn. That is the
13
+ same class as the enrolment failures earlier tonight: a capability described as reachable that
14
+ had no reachable surface.
15
+
16
+ ## Decision-point inventory
17
+
18
+ None added. The screen holds no authority: it renders a plan the SERVER composed and posts back
19
+ the plan identity plus the operator's PIN. The server verifies the PIN and derives what to apply
20
+ from its own rendered plan. The UI cannot arm, adjust, or unfreeze anything on its own.
21
+
22
+ ## 1. Over-block
23
+
24
+ `validateCaps` refuses client-side before asking for a plan: a missing ceiling, a non-number, a
25
+ non-positive value, or a daily ceiling above the lifetime ceiling. The last could arguably be
26
+ allowed (the server may accept it), but a daily ceiling that can never bind is almost certainly a
27
+ typo, and the message says why rather than just refusing.
28
+
29
+ ## 2. Under-block
30
+
31
+ The client does no authorization at all — deliberately. Every real refusal (bad PIN, money layer
32
+ off, unreadable caps store, expired plan) is the server's, and the UI only renders the reason.
33
+
34
+ ## 3. Level-of-abstraction fit
35
+
36
+ Plan rendering stays server-side; the client never composes the sentence the operator approves.
37
+ That is what makes "a field you never saw cannot land" true, and it would be false if the UI
38
+ built its own summary text.
39
+
40
+ ## 4. Signal vs authority
41
+
42
+ The UI is pure presentation plus transport. The PIN gate is unchanged and remains server-side.
43
+
44
+ ## 5. Interactions
45
+
46
+ The panel renders from the SAME `/routing-spend/caps` payload the read-only glance above it uses,
47
+ so the door list and its live/frozen wording cannot drift from the figures. Editing any input or
48
+ changing the door clears the pending plan, so a PIN can never be applied to a plan the operator
49
+ has since edited away from.
50
+
51
+ ## 6. Multi-machine posture
52
+
53
+ Machine-local BY DESIGN — the dashboard is served per machine and the PIN is a per-machine
54
+ secret. Go-live already carries a `designatedMachineId` server-side; the UI does not touch it.
55
+
56
+ ## 7. Failure modes
57
+
58
+ Every money-layer failure routes through `moneyLayerNote`, which distinguishes: money layer off
59
+ (names it as the operator's own switch), caps store unreadable (refusing to guess), rejected PIN,
60
+ and everything else. The default says "nothing has changed", which is true on every path because
61
+ the server fails closed. The module has no I/O and every exported function is total (tested
62
+ against undefined/null/junk).
63
+
64
+ **Security**: the PIN never appears in a preview request (pure-function test asserts it), is a
65
+ `type=password` field, is cleared on both success and failure, and is never placed in a URL. All
66
+ rendering is textContent; a hostile plan text is tested for.
67
+
68
+ ## 8. Rollback cost
69
+
70
+ Delete the `spendArming` div and the `renderSpendArming(caps)` call. The module is inert without
71
+ them. No state, no migration, no config.
72
+
73
+ ## Evidence
74
+
75
+ 31 unit tests against the shipped module in a real DOM, plus wiring tests that assert every
76
+ `/dashboard/*.js` the page imports exists on disk — the guard against the "wired but unreachable"
77
+ shape that caused this whole thread.
78
+
79
+ Shown capable of failing, independently: leaking the PIN into the preview request fails exactly
80
+ the security test; replacing the honest 503 message with a generic one fails exactly the two
81
+ tests that pin it. A CONTROL asserts an ordinary network failure does NOT claim the money layer
82
+ is off — without it, every failure would send the operator to flip a switch that is already on.
83
+
84
+ **Not yet proven live.** The money layer is off on this agent, so the arming path cannot be
85
+ driven end-to-end until the operator enables it. That is stated rather than papered over: this
86
+ review does not claim live-channel proof it does not have.
@@ -0,0 +1,70 @@
1
+ # Side-Effects Review — group the accounts list by provider
2
+
3
+ ## Summary
4
+
5
+ `renderAccounts` drew accounts in a flat pool-ordered run, interleaving providers. It now draws
6
+ each provider's accounts together under a heading, via a new pure `groupAccountsByProvider`.
7
+
8
+ ## Decision-point inventory
9
+
10
+ None. This is presentation ordering. It gates nothing, grants nothing, and reads no state
11
+ beyond the accounts array it is already handed.
12
+
13
+ ## 1. Over-block / 2. Under-block
14
+
15
+ Not applicable — no admission decision. The nearest analogue is dropping an account from the
16
+ list, which a test forbids explicitly (every account renders exactly once; count asserted).
17
+
18
+ ## 3. Level-of-abstraction fit
19
+
20
+ Grouping belongs in the renderer, not the API. The pool's order is meaningful and the server
21
+ should keep returning it as-is; how it is PRESENTED is a dashboard concern. Sorting server-side
22
+ would have imposed one view on every consumer.
23
+
24
+ ## 4. Signal vs authority
25
+
26
+ Not applicable — no decision logic.
27
+
28
+ ## 5. Interactions
29
+
30
+ The in-use marker, quota bars, status, email and the no-quota fallback all render through the
31
+ unchanged card path; grouping only reorders and interleaves headings. The heading marker is a
32
+ sentinel object (`__providerHeading`) consumed at the top of the same loop, so there is one
33
+ card-rendering path, not two. A test asserts the in-use marker survives grouping.
34
+
35
+ ## 6. Multi-machine posture
36
+
37
+ Machine-local BY DESIGN — this is dashboard rendering of data the server already merged.
38
+ Nothing replicates. The pool view's own machine tagging is untouched.
39
+
40
+ ## 7. Failure modes
41
+
42
+ `groupAccountsByProvider` handles a non-array (returns []), a missing provider (grouped under
43
+ `Other`), and a non-string provider (coerced to the empty key). It has no I/O and cannot throw
44
+ for any accounts array the existing renderer would have accepted.
45
+
46
+ **Security**: the heading is the only new dynamic string reaching the DOM. It goes through
47
+ `friendlyProvider`, which maps known providers and sanitizes everything else, and is written
48
+ via the module's `el()` textContent helper. A test plants `<img src=x onerror=...>` as a
49
+ provider and asserts no live element and no `onerror` attribute survives.
50
+
51
+ ## 8. Rollback cost
52
+
53
+ Change one call site back to `for (const a of accounts)`. No state, no migration, no config.
54
+
55
+ ## Evidence
56
+
57
+ 8 new tests in the existing jsdom suite, which drives the SHIPPED `dashboard/subscriptions.js`
58
+ against a real DOM. Shown capable of failing: reverting the call site fails exactly the 4
59
+ grouping assertions while the 4 invariant tests keep passing — the correct signature, since
60
+ those describe behaviour that must hold either way.
61
+
62
+ Controls carry the weight here: a single-provider list is asserted UNCHANGED (no heading), and
63
+ account order within a group is asserted preserved. Without those, "grouping works" would pass
64
+ equally well against a change that sorted everything alphabetically and added a redundant
65
+ heading to every single-provider install.
66
+
67
+ One test was WRONG on first run and the code was right: it asserted `innerHTML` lacks the
68
+ substring "onerror", but correctly-escaped text still reads `&lt;img src=x onerror=...&gt;`.
69
+ The assertion now tests the real property — no live element, no such attribute, heading has no
70
+ element children — rather than the absence of characters.