instar 1.3.1170 → 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,28 @@
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
+
3637
3665
  /* Provider group heading — only rendered when more than one provider is
3638
3666
  enrolled, so a single-provider install looks exactly as it did before. */
3639
3667
  .sub-provider-heading { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
@@ -6010,6 +6038,10 @@
6010
6038
 
6011
6039
  const spec = glance.spendGlanceSpec(document, summary, caps);
6012
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);
6013
6045
  }
6014
6046
 
6015
6047
  async function loadResources() {
@@ -8635,6 +8667,217 @@
8635
8667
  return __glanceModule;
8636
8668
  }
8637
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
+
8638
8881
  async function loadCommitments() {
8639
8882
  const glanceRoot = document.getElementById('commitmentsGlance');
8640
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
+ }
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1170",
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": "a280711e801cac1066b109eb5400956a867d0028f6b5825ad21b7b1ccde1f49f",
2
+ "sha256": "878fad6f61a7948606c4c2d116b92656e0449bab6e005987433fc6bcef6309e6",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1170"
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.1170"
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.1170",
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-16T03:47:55.829Z",
5
- "instarVersion": "1.3.1170",
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.1170",
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": "a280711e801cac1066b109eb5400956a867d0028f6b5825ad21b7b1ccde1f49f",
2
+ "sha256": "878fad6f61a7948606c4c2d116b92656e0449bab6e005987433fc6bcef6309e6",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1170"
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.1170"
5
+ "packageVersion": "1.3.1171"
6
6
  }
@@ -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.