Skip to content

Commit 8ed8dd8

Browse files
authored
Replay CapabilityLedger contract and demo surface
Clean replay of #14 onto current main after #17 landed local Workspace Operations shell contracts. Adds the capability-ledger package, ledger schema, runtime implementation, node:test coverage, PDF viewer demo capability reconciliation surface, and lockfile update. Connector-visible workflow/status data was absent for PR head 6b29600. The replay is mergeable and scoped to package/demo/test/lockfile files; package test command is node --test tests/ledger.test.js.
1 parent 779da84 commit 8ed8dd8

6 files changed

Lines changed: 990 additions & 1 deletion

File tree

apps/pdf-viewer-demo/index.html

Lines changed: 321 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,329 @@
44
<meta charset="utf-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<title>sourceos-shell PDF Viewer Demo</title>
7+
<style>
8+
:root {
9+
--color-enabled: #1a7a42;
10+
--color-pending: #a65a00;
11+
--color-blocked: #b00020;
12+
--color-degraded: #7a6a00;
13+
--color-failed: #7a0000;
14+
--color-text: #1a1a1a;
15+
--color-bg: #f5f5f5;
16+
--color-surface: #ffffff;
17+
--color-border: #d0d0d0;
18+
}
19+
* { box-sizing: border-box; margin: 0; padding: 0; }
20+
body {
21+
font-family: system-ui, sans-serif;
22+
background: var(--color-bg);
23+
color: var(--color-text);
24+
padding: 1.5rem;
25+
}
26+
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
27+
.subtitle { color: #555; margin-bottom: 1.5rem; font-size: 0.9rem; }
28+
h2 { font-size: 1rem; margin: 1.25rem 0 0.5rem; }
29+
30+
/* Capability ledger panel */
31+
#ledger-panel {
32+
background: var(--color-surface);
33+
border: 1px solid var(--color-border);
34+
border-radius: 6px;
35+
overflow: hidden;
36+
}
37+
#ledger-panel table {
38+
width: 100%;
39+
border-collapse: collapse;
40+
font-size: 0.85rem;
41+
}
42+
#ledger-panel th {
43+
background: #f0f0f0;
44+
text-align: left;
45+
padding: 0.5rem 0.75rem;
46+
border-bottom: 1px solid var(--color-border);
47+
font-weight: 600;
48+
}
49+
#ledger-panel td {
50+
padding: 0.45rem 0.75rem;
51+
border-bottom: 1px solid #ececec;
52+
vertical-align: top;
53+
}
54+
#ledger-panel tr:last-child td { border-bottom: none; }
55+
56+
.badge {
57+
display: inline-block;
58+
padding: 0.15em 0.55em;
59+
border-radius: 3px;
60+
font-size: 0.8rem;
61+
font-weight: 600;
62+
color: #fff;
63+
}
64+
.badge-enabled { background: var(--color-enabled); }
65+
.badge-declared,
66+
.badge-requested,
67+
.badge-negotiating,
68+
.badge-available { background: var(--color-pending); }
69+
.badge-blocked_by_policy { background: var(--color-blocked); }
70+
.badge-degraded { background: var(--color-degraded); }
71+
.badge-failed,
72+
.badge-unsupported_by_runtime,
73+
.badge-unsupported_by_server { background: var(--color-failed); }
74+
.badge-missing_plugin,
75+
.badge-missing_schema { background: #5a5a8a; }
76+
77+
.refs { font-size: 0.78rem; color: #555; margin-top: 0.2rem; }
78+
.refs code { font-size: 0.78rem; background: #f0f0f0; padding: 0.05em 0.3em; border-radius: 2px; }
79+
.conflict-warning { color: var(--color-blocked); font-size: 0.78rem; }
80+
81+
/* Feature gate demo */
82+
#feature-gate {
83+
margin-top: 1.25rem;
84+
background: var(--color-surface);
85+
border: 1px solid var(--color-border);
86+
border-radius: 6px;
87+
padding: 1rem;
88+
}
89+
#feature-gate p { font-size: 0.9rem; margin-bottom: 0.75rem; }
90+
.feature-block {
91+
display: flex; align-items: center; gap: 0.75rem;
92+
margin-bottom: 0.5rem;
93+
}
94+
.feature-block button {
95+
padding: 0.35rem 1rem;
96+
border: none;
97+
border-radius: 4px;
98+
cursor: pointer;
99+
font-size: 0.85rem;
100+
font-weight: 600;
101+
}
102+
.feature-block button:disabled {
103+
opacity: 0.4;
104+
cursor: not-allowed;
105+
}
106+
.feature-block button.btn-ok { background: var(--color-enabled); color: #fff; }
107+
.feature-block button.btn-deny { background: #ddd; color: #444; }
108+
#feature-output {
109+
margin-top: 0.5rem;
110+
font-size: 0.85rem;
111+
min-height: 1.2em;
112+
}
113+
</style>
7114
</head>
8115
<body>
9116
<h1>sourceos-shell PDF Viewer Demo</h1>
10-
<p>PDF-first runtime scaffold placeholder.</p>
117+
<p class="subtitle">PDF-first runtime scaffold — CapabilityLedger surface</p>
118+
119+
<h2>Capability Ledger</h2>
120+
<div id="ledger-panel">
121+
<table>
122+
<thead>
123+
<tr>
124+
<th>Capability</th>
125+
<th>State</th>
126+
<th>Owner</th>
127+
<th>Policy / Evidence</th>
128+
<th>Conflicts</th>
129+
</tr>
130+
</thead>
131+
<tbody id="ledger-body">
132+
<tr><td colspan="5" style="color:#888;padding:1rem;">Initialising ledger…</td></tr>
133+
</tbody>
134+
</table>
135+
</div>
136+
137+
<h2>Feature Gate Demo</h2>
138+
<div id="feature-gate">
139+
<p>Feature use is blocked until the ledger reports <strong>enabled</strong>.</p>
140+
<div class="feature-block">
141+
<button id="btn-pdf-view" class="btn-deny" disabled>View PDF</button>
142+
<span id="gate-pdf-view"></span>
143+
</div>
144+
<div class="feature-block">
145+
<button id="btn-pdf-sign" class="btn-deny" disabled>Sign PDF</button>
146+
<span id="gate-pdf-sign"></span>
147+
</div>
148+
<div id="feature-output"></div>
149+
</div>
150+
151+
<script type="module">
152+
// ── Inline CapabilityLedger (browser-compatible, no bundler needed) ────
153+
154+
const CAPABILITY_STATES = [
155+
'declared','requested','negotiating','available','enabled',
156+
'degraded','blocked_by_policy','unsupported_by_runtime',
157+
'unsupported_by_server','missing_plugin','missing_schema','failed',
158+
];
159+
const CAPABILITY_OWNERS = ['UI','runtime','server','plugin','policy'];
160+
161+
function buildReceipt(capabilityId, state, owner, opts = {}) {
162+
if (!CAPABILITY_STATES.includes(state)) throw new TypeError(`Invalid state: "${state}"`);
163+
if (!CAPABILITY_OWNERS.includes(owner)) throw new TypeError(`Invalid owner: "${owner}"`);
164+
return {
165+
capabilityId,
166+
state,
167+
owner,
168+
timestamp: new Date().toISOString(),
169+
policyDecisionRef: opts.policyDecisionRef ?? null,
170+
evidenceRefs: opts.evidenceRefs ?? [],
171+
conflictWarnings: opts.conflictWarnings ?? [],
172+
};
173+
}
174+
175+
class CapabilityLedger {
176+
constructor() { this._receipts = new Map(); }
177+
178+
_emit(id, state, owner, opts = {}) {
179+
const existing = this._receipts.get(id);
180+
const conflicts = [...(existing?.conflictWarnings ?? []), ...(opts.conflictWarnings ?? [])];
181+
const r = buildReceipt(id, state, owner, { ...opts, conflictWarnings: conflicts });
182+
this._receipts.set(id, r);
183+
return r;
184+
}
185+
186+
declare(id, owner, opts = {}) { return this._emit(id, 'declared', owner, opts); }
187+
request(id, owner, opts = {}) { return this._emit(id, 'requested', owner, opts); }
188+
negotiate(id, owner, opts = {}) { return this._emit(id, 'negotiating', owner, opts); }
189+
setAvailable(id, owner, opts = {}) { return this._emit(id, 'available', owner, opts); }
190+
enable(id, owner, pdr = null, ev = []) { return this._emit(id, 'enabled', owner, { policyDecisionRef: pdr, evidenceRefs: ev }); }
191+
deny(id, owner, pdr = null, ev = []) { return this._emit(id, 'blocked_by_policy', owner, { policyDecisionRef: pdr, evidenceRefs: ev }); }
192+
degrade(id, owner, ev = []) { return this._emit(id, 'degraded', owner, { evidenceRefs: ev }); }
193+
setUnsupportedByRuntime(id, owner, ev = []) { return this._emit(id, 'unsupported_by_runtime', owner, { evidenceRefs: ev }); }
194+
setUnsupportedByServer(id, owner, ev = []) { return this._emit(id, 'unsupported_by_server', owner, { evidenceRefs: ev }); }
195+
setMissingPlugin(id, owner, ev = []) { return this._emit(id, 'missing_plugin', owner, { evidenceRefs: ev }); }
196+
setMissingSchema(id, owner, ev = []) { return this._emit(id, 'missing_schema', owner, { evidenceRefs: ev }); }
197+
fail(id, owner, ev = []) { return this._emit(id, 'failed', owner, { evidenceRefs: ev }); }
198+
199+
logConflict(id, warning) {
200+
const existing = this._receipts.get(id);
201+
if (existing) { existing.conflictWarnings.push(warning); }
202+
else { this._emit(id, 'declared', 'runtime', { conflictWarnings: [warning] }); }
203+
}
204+
205+
reconcile() {
206+
const enabled = [], pending = [], conflicted = [];
207+
for (const [id, r] of this._receipts) {
208+
(r.state === 'enabled' ? enabled : pending).push(id);
209+
if (r.conflictWarnings.length > 0) conflicted.push(id);
210+
}
211+
return { enabled, pending, conflicted };
212+
}
213+
214+
getState(id) { return this._receipts.get(id)?.state ?? null; }
215+
getReceipt(id) { return this._receipts.get(id) ?? null; }
216+
getAll() { return Array.from(this._receipts.values()); }
217+
isEnabled(id) { return this.getState(id) === 'enabled'; }
218+
}
219+
220+
// ── Bootstrap ledger with PDF viewer demo capabilities ────────────────
221+
222+
const ledger = new CapabilityLedger();
223+
224+
// pdf-viewer: fully enabled
225+
ledger.declare('pdf-viewer', 'UI');
226+
ledger.request('pdf-viewer', 'runtime');
227+
ledger.negotiate('pdf-viewer', 'server');
228+
ledger.setAvailable('pdf-viewer', 'runtime');
229+
ledger.enable(
230+
'pdf-viewer', 'runtime',
231+
'policy:allow-pdf-viewer:v1',
232+
['config:features/pdf-viewer:enabled', 'plugin:pdf-renderer:loaded'],
233+
);
234+
235+
// pdf-sign: missing plugin
236+
ledger.declare('pdf-sign', 'UI');
237+
ledger.setMissingPlugin(
238+
'pdf-sign', 'plugin',
239+
['plugin:ink-sign:not-installed'],
240+
);
241+
242+
// live-collab: unsupported by server
243+
ledger.declare('live-collab', 'UI');
244+
ledger.setUnsupportedByServer(
245+
'live-collab', 'server',
246+
['server:collab-api:not-supported'],
247+
);
248+
249+
// analytics: blocked by policy
250+
ledger.declare('analytics', 'UI');
251+
ledger.deny(
252+
'analytics', 'policy',
253+
'policy:deny-analytics:privacy-v3',
254+
['audit:privacy-policy:2026-01'],
255+
);
256+
257+
// graph-view: degraded (plugin loaded but slow)
258+
ledger.declare('graph-view', 'UI');
259+
ledger.degrade('graph-view', 'runtime', ['perf:graph-render:below-threshold']);
260+
ledger.logConflict('graph-view', 'UI requested enabled but runtime reports degraded performance');
261+
262+
// ── Render ledger table ───────────────────────────────────────────────
263+
264+
function esc(str) {
265+
return String(str ?? '')
266+
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
267+
}
268+
269+
function renderRefs(refs) {
270+
if (!refs || refs.length === 0) return '—';
271+
return refs.map(r => `<code>${esc(r)}</code>`).join(' ');
272+
}
273+
274+
function renderRow(receipt) {
275+
const badgeClass = `badge-${receipt.state}`;
276+
const pdr = receipt.policyDecisionRef
277+
? `<div class="refs">policy: <code>${esc(receipt.policyDecisionRef)}</code></div>`
278+
: '';
279+
const ev = receipt.evidenceRefs.length
280+
? `<div class="refs">evidence: ${renderRefs(receipt.evidenceRefs)}</div>`
281+
: '';
282+
const conflicts = receipt.conflictWarnings.length
283+
? receipt.conflictWarnings.map(w =>
284+
`<div class="conflict-warning">⚠ ${esc(w)}</div>`
285+
).join('')
286+
: '—';
287+
288+
return `<tr>
289+
<td>${esc(receipt.capabilityId)}</td>
290+
<td><span class="badge ${badgeClass}">${esc(receipt.state)}</span></td>
291+
<td>${esc(receipt.owner)}</td>
292+
<td>${pdr}${ev || (pdr ? '' : '—')}</td>
293+
<td>${conflicts}</td>
294+
</tr>`;
295+
}
296+
297+
const tbody = document.getElementById('ledger-body');
298+
tbody.innerHTML = ledger.getAll().map(renderRow).join('');
299+
300+
// ── Feature gate buttons ──────────────────────────────────────────────
301+
302+
function updateGate(capabilityId, btnId, gateId) {
303+
const btn = document.getElementById(btnId);
304+
const gate = document.getElementById(gateId);
305+
const enabled = ledger.isEnabled(capabilityId);
306+
const state = ledger.getState(capabilityId) ?? 'unknown';
307+
308+
btn.disabled = !enabled;
309+
btn.className = enabled ? 'btn-ok' : 'btn-deny';
310+
gate.textContent = enabled
311+
? '✓ enabled'
312+
: `✗ blocked — state: ${state}`;
313+
gate.style.color = enabled ? 'var(--color-enabled)' : 'var(--color-blocked)';
314+
}
315+
316+
updateGate('pdf-viewer', 'btn-pdf-view', 'gate-pdf-view');
317+
updateGate('pdf-sign', 'btn-pdf-sign', 'gate-pdf-sign');
318+
319+
const output = document.getElementById('feature-output');
320+
321+
document.getElementById('btn-pdf-view').addEventListener('click', () => {
322+
if (!ledger.isEnabled('pdf-viewer')) return;
323+
output.textContent = '📄 PDF viewer opened (capability confirmed enabled by ledger).';
324+
});
325+
326+
document.getElementById('btn-pdf-sign').addEventListener('click', () => {
327+
if (!ledger.isEnabled('pdf-sign')) return;
328+
output.textContent = '✍ PDF signed.';
329+
});
330+
</script>
11331
</body>
12332
</html>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "capability-ledger",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"exports": {
7+
".": "./src/index.js",
8+
"./schema": "./src/schema.js"
9+
},
10+
"scripts": {
11+
"test": "node --test tests/ledger.test.js"
12+
}
13+
}

0 commit comments

Comments
 (0)