Home / Replicate / The reader API: a map, and a review
Phase 0 of the move: map
The reader API: a map, and a review
Everything a reader does on this site, before it moves: the files and their hashes, what a reader can click, type or pay, where their data goes, the contracts other sites and agents program against, and what a line-by-line review found.
“API” here means what the brief means by it: client-side. Components, formats and contracts the browser runs, with no server of ours and nothing to deploy but files. It is what other sites and agents program against: the meter’s configuration, the reading-list link, the article numbers, the persona links, the share’s envelope. This page maps all of it as it is at v0.9.3, before anything moves, and reviews it. It is regenerated on every build from the tree, and from the tables kept beside the code in admin/build/api_map.py; the same map is map.json, and the review is also a note.
In numbers
| Reader code that runs in a browser | 3,344 lines in 16 files; 5,697 more lines in 20 frozen files of earlier meter versions, served and never changed |
| Pages that carry it | 21 pages written for it; the balance chip and a charged-page element on every page of the site |
| Views, controls, inputs | 12 views of one element, 56 kinds of control, 11 ways data comes in, 9 ways it goes out |
| Contracts | 14, listed below; the two that other sites and agents already program against are the reading-list link and articles/ids.json |
| Tests | 171 check calls in three files (loops make more checks at run time), now run in CI before a tag (R11) |
| Pages with a Content-Security-Policy | 0 (R10) |
| Upstreams | two: sgit.ai v0.7.61 for the account pages, subscribe, the contact file and the meter to 1.3.1, rewritten on import to this site's 1.4.0 (R12) |
The files
The code a reader runs, where it comes from, and what each file reaches for. 5 of the 16 code files come from sgit.ai byte for byte and cannot be changed here by a rule of the import; that is R4 and R12 below.
| File | What it is | From | Lines | Reaches | SHA-256 (normalised for pages) |
|---|---|---|---|---|---|
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter.js | SG Meter, the element: twelve views, the reader card, the share form, the graph | this site, from sgit.ai's v1.3.1 | 815 | 2 fetch, sg:meter.changed, confirm(), WebCrypto | 82f8c09842290336 |
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter-core.js | SG Meter, the meter: prices, the charge, ratings, declining, credit, personas, picks, the share text | this site, from sgit.ai's v1.3.1 | 511 | 1 fetch, localStorage, sessionStorage, sg:meter.changed, sg:meter.charged, sg:meter.declined, sg:meter.persona, sg:meter.reset, sg:meter.restored, sg:meter.shared, sg:meter.toppedup | 6a74b1183c766d5a |
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter.css | SG Meter styles, adopted into the shadow root | this site, from sgit.ai's v1.3.1 | 188 | — | 98a8640466813589 |
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter.tpl | SG Meter markup (one div) | this site, from sgit.ai's v1.3.1 | 1 | — | 104c7c1ded976408 |
assets/components/base/v1/v1.0/v1.0.0/sg-component.js | The component base: fetches the .tpl and .css beside a component and calls onReady() | sgit.ai, verbatim | 118 | 1 fetch, sg-meter:error, innerHTML | e566a78ad98fbd18 |
for/reading-list.js | A reading list in a link: parse, build, show, open as a persona | this site | 343 | 1 fetch | 5f8fa1378b99c24c |
for/make.js | The page that makes a reading list and previews it | this site | 254 | 1 fetch | ec9abc1f40b5f564 |
for/for.css | Styles for the two reading-list pages | this site | 109 | — | cca712d5810b3085 |
assets/subscribe.js | The subscribe form: encrypts the address and posts it to the lane | sgit.ai, verbatim | 117 | 2 fetch, WebCrypto | fe2eec58c28f15e5 |
wire/vault-reader.js | Reads an sgit vault in the browser with a published read key: ref, commit, tree, blobs, decrypted with Web Crypto; the markdown renderer | this site, vendored from sgit.ai's vault-docs.js | 182 | 1 fetch, WebCrypto | 332509b6be44538d |
wire/wire.js | The Industry Wire: the edition with its lens, the sources, the documents, drawn from the vault as text | this site | 214 | localStorage, innerHTML | 91289d4ad810fcd6 |
wire/wire.css | Styles for the wire pages | this site | 76 | — | 51fc706e9725f4b6 |
admin/build/wire-vault.json | The wire's vault: endpoint, vault id, the public read key (the only key on this site) | this site | 14 | — | c8c84bea5a65df09 |
wire/snapshot.json | The wire as it was in the vault at the last release: headlines and primary links, the fallback and the agents' index | this site, generated | 412 | — | e3adb1150066e6e9 |
assets/vault-ui-embed.js | The study page: embeds the vault browser from dev.vault.sgraph.ai and hands it a read key | sgit.ai, verbatim | 126 | hosts: dev.vault.sgraph.ai, innerHTML | 308af99563423f5e |
assets/shots.js | Figures: sets each image's address at runtime | sgit.ai, verbatim | 167 | 1 fetch | 3e6d8149ae48e24e |
assets/site.js | Page behaviour: nav, copy buttons, reveal-on-scroll | sgit.ai, verbatim | 81 | — | 0069c69190c8afc1 |
assets/nav.js | Nav behaviour on the pages this site writes | this site | 42 | — | bf18958cd99e10b5 |
.well-known/sgit-subscribe.json | The lane and the public key the share and the subscription are encrypted to and written into | sgit.ai, verbatim | 25 | hosts: dev.send.sgraph.ai | 1414b8bb22c50b86 |
articles/ids.json | A number for every article: the contract reading lists are built on | this site, generated | 1,246 | — | 9bf4b1222af05d71 |
persona/links.json | The persona links, for whoever writes to someone | this site, generated | 268 | — | 7b7bc6c112744e50 |
articles/graphs.json | The feed the picks and the graph read: every article with topics, tags and citations | sgit.ai, imported | 18,379 | — | a889adb94db50b32 |
The page loader: an inline script on every imported page (17 lines, SHA-256 a7939d0c7aaca350) that loads the stylesheet and assets/site.js, by the vault bridge when the page is inside a vault and otherwise by fetching the text and evaluating it. 350 pages carry a script that evaluates fetched text (R10). The 20 files of earlier meter versions (1.0.0 to 1.3.1, 5,697 lines) are published artefacts on immutable paths; they move frozen, and their only test is that they are still served byte for byte.
Labs, beside the reader code and not part of it
Code that runs in a browser on this site, is not on a reader’s path, and will move with the rest: today, the passkey lab. It makes no change to the reading account and is counted apart.
| File | What it is | Lines | SHA-256 |
|---|---|---|---|
passkey/lab.js | The passkey lab: WebAuthn with PRF, the door derivation, sgit's key derivation, a stand-alone vault client | 413 | 4c609fa85e041be3 |
passkey/lab.css | its styles | 28 | d6a2b51445e89ca9 |
passkey/vectors.json | its test vectors, from sgit's Python | 19 | cfebdc0fe8d139ae |
passkey/index.html | the page | 297 | 2734d500402f16ee |
The pages
| Page | What | Lines | SHA-256, chrome and stamps normalised |
|---|---|---|---|
account/index.html | the reading account | 250 | fbe19375c9e0b1e9 |
account/newsroom.html | the reader's newsroom | 245 | 25ce5685c477f0fc |
account/personas.html | the personas | 245 | 645413ccdf42b949 |
account/share.html | send us your reading | 247 | f75680bc60808188 |
account/top-up.html | the top-up | 245 | ea1f9348b198c8fb |
account/topped-up.html | the page a payment returns to | 244 | 518f95ea18b117b4 |
subscribe/index.html | subscribe | 265 | c2027e60d91dbc82 |
persona/index.html | the persona picker | 227 | eb1fb60d66f1bc01 |
persona/founder/index.html | a persona link | 234 | 7f1158881c4b5708 |
persona/journalist/index.html | a persona link | 234 | 5e7abe06b002715d |
persona/security/index.html | a persona link | 234 | ef7b2f455eb26938 |
persona/builder/index.html | a persona link | 234 | 531be95b48cd5e11 |
persona/board/index.html | a persona link | 234 | 9db951fc6bd34999 |
for/index.html | a reading list | 241 | 313dac3b3f31ae29 |
for/make.html | make a reading list | 231 | 3c827cd696b202f3 |
wire/index.html | the Industry Wire's edition | 56 | ebb2c8b658df44fc |
wire/sources.html | the wire's sources | 52 | 9e4d5619a961370c |
wire/how.html | how the wire is read, and its documents | 56 | f331d73c149731c1 |
meter/index.html | SG Meter, documented | 386 | a55c58061932dc93 |
meter/security.html | the meter's security model | 271 | aa41ecfcbd9cd211 |
demos/vaults/newsroom-synthetic-users/index.html | the synthetic-readers study, with the embedded vault | 332 | 0a2a202f29029b4f |
Which pages carry which view: account on 1, landing on 6, newsroom on 1, page on 366, personas on 1, share on 1, topped-up on 1, topup on 1. The balance chip is in the nav of every page; every page is a charged or free page.
Capabilities and web controls
Everything a reader can click, type or pay, by view, with the call it makes. Where a control changes money or a persona, the call is named so a test can target it.
balance · every page (the nav chip)
| Control | Label | What it does |
|---|---|---|
| link | the balance | opens the account page |
page · every charged page, at the foot
| Control | Label | What it does |
|---|---|---|
| link | How paying for reading works here | opens links.how |
| link | Balance | opens the account page |
| button | + Add to your reading list / ✓ In your reading list | meter.curate(slug, 'on' | '') |
| 5 buttons × 3 | How useful was this? · More like this? · Level of detail | meter.rate(field, 1–5); usefulness re-prices the page |
| link | Your newsroom | opens links2.newsroom |
| button | Something wrong with this page? Don’t charge me | shows the four reasons |
| 4 buttons | the reasons | meter.decline(reason): refunds the page |
account · account/index.html
| Control | Label | What it does |
|---|---|---|
| link | Top up £5.00 | navigates to the Stripe payment link (topup.link) |
| button | Export as JSON | downloads reading-account.json (the whole state) |
| links | Your newsroom · Your personas · Send us your reading | own pages |
| links | one per history row | the page read, from the log's own path |
| file input | Restore from an export… | confirm(), then meter.restore(text) |
topup · account/top-up.html
| Control | Label | What it does |
|---|---|---|
| link | Pay £5.00 | navigates to the Stripe payment link; with no link, the simulated cart |
topped-up · account/topped-up.html
| Control | Label | What it does |
|---|---|---|
| on load | ?session_id=… | meter.credit(amount, 'link', id), once per id, unverified |
| link | Your account |
picks · the front page
| Control | Label | What it does |
|---|---|---|
| links | one per pick | opens the article |
newsroom · account/newsroom.html, and under a persona banner
| Control | Label | What it does |
|---|---|---|
| buttons | the persona chips | meter.setActive(id) |
| link | + Add or manage personas | the personas page |
| link | A newsroom designed for you | the share page (not when compact) |
| 2 buttons per pick | Add to reading list · Not for this persona | meter.curate(slug, 'on' | 'off', active) |
| button per kept / put-out article | remove · bring back | meter.curate(slug, '') |
| buttons | Add this persona (per preset) · Add a blank persona | meter.addPersona(preset | '') |
personas · account/personas.html
| Control | Label | What it does |
|---|---|---|
| input + button per persona | name · Rename | meter.renamePersona(id, name) |
| button per persona | Make active | meter.setActive(id) |
| link per persona | Open its newsroom | setActive, then the newsroom page |
| button per persona | Remove | confirm(), then meter.removePersona(id) |
| buttons | the preset cards | meter.addPersona |
graph · account and newsroom pages
| Control | Label | What it does |
|---|---|---|
| links | one per article dot | opens the article |
link · pages that carry it
| Control | Label | What it does |
|---|---|---|
| link | Your newsroom |
landing · persona/index.html and persona/<id>/
| Control | Label | What it does |
|---|---|---|
| on load | the persona named by the attribute or the #fragment | meter.visit(id): added as a visitor, made active, nothing charged |
| buttons | Keep this persona · Remove it | meter.keepPersona(id) · meter.removePersona(id) |
| button | Read as <name> again | meter.visit(id) after a removal |
| links | Your personas · Other personas | own pages |
| per preset (the picker) | Read as this persona · Copy link | opens persona/<id>/ · clipboard |
share · account/share.html
| Control | Label | What it does |
|---|---|---|
| button | Copy to clipboard · Copy it instead | clipboard; meter.noteShared('copied') |
| inputs | name · email (required) · note · consent · newsletter · a hidden honeypot | the form; nothing is sent without consent |
| submit | Send it, encrypted | fetch the contact file, verify the key's fingerprint, encrypt an .eml, POST to the lane |
reading list (for/) · for/index.html
| Control | Label | What it does |
|---|---|---|
| on load | the fields after the # | parse(); visitList(): a visitor persona named for the two people, its articles as the reading list |
| link | Start reading | the first article |
| link | Reply to <sender> | mailto:, only when the link carries e= |
| buttons | Keep this list as a persona · Remove it · Add it again | meter.keepPersona · meter.removePersona · visitList |
| links | every article, every persona or collection to follow | own pages |
make a reading list · for/make.html
| Control | Label | What it does |
|---|---|---|
| inputs | From · To · a note · your email | validated as the recipient's page validates them |
| search and two selects | title, topic or number · topic · persona | filters the article list |
| 2 buttons per article | Read · Look | puts the article in a group |
| per chosen article | ↑ · ↓ · To look / To read · Remove, and drag | orders the list |
| checkboxes | personas and collections to follow | |
| button | Copy link | clipboard |
| link | Open as the recipient | for/#… in a new tab |
| input + button | Edit an existing link · Load | parse() into the form |
| button | Clear the list |
subscribe form · subscribe/index.html
| Control | Label | What it does |
|---|---|---|
| inputs | email · name · consent · a hidden honeypot | |
| submit | Subscribe | fetch the contact file, verify the fingerprint, encrypt an .eml, POST to the lane; on failure, a mailto: link with the same text |
the study page · demos/vaults/newsroom-synthetic-users/
| Control | Label | What it does |
|---|---|---|
| on load | two iframes from dev.vault.sgraph.ai | the published read key is handed over postMessage; the vault is decrypted in the frame |
article foot · every article
| Control | Label | What it does |
|---|---|---|
| link | start a reading list with it | for/make.html#r=<number> |
Inputs
| Where data comes in | What | Who controls it, and how it is checked | In the code |
|---|---|---|---|
| Element attributes | view · kind · date · topics · title · quiet · count · persona · compact | the page's own markup (trusted: it is this site's build) | sg-meter.js |
| The page's config | <script type="application/json" id="sg-meter-config"> | this site's build, written once per page; every key has a default | sg-meter-core.js _config() |
| The address: query | ?session_id= on account/topped-up.html | whoever made the link; any value is credited once (accepted, documented) | _toppedUp() |
| The address: fragment | #<persona id> on persona/; #from,to,r,s,f,n,e on for/ and for/make.html | whoever made the link; validated field by field, the rest dropped and listed | _named(), reading-list.js parse() |
| The address: path | location.pathname | the charge is keyed on it; the slug is taken from it; the share records it | open(), _page(), _send() |
| Local storage | sgit.meter.v1 (the state), sgit.meter.v1.seen (session: the charge per path) | this browser, written by the meter; also by the reader, who may edit it (accepted) | _read(), _seen() |
| A file | Restore from an export… | whoever made the file; checked for balance:number and log:array only | restore() |
| Forms | the share form, the subscribe form, the make page | the reader; email checked by a loose pattern, names by a strict one | _send(), subscribe.js, make.js |
| The wire's vault | data/bundle.json, data/deliveries.json, docs/*.md, decrypted in the browser | whoever holds the vault's write key (the wire's agents); drawn as text, links only from http(s) values, markdown rendered by an escaping renderer; the vault's own HTML is not used | wire/wire.js |
| Own-origin data | articles/graphs.json · articles/ids.json · .well-known/sgit-subscribe.json · sg-meter.tpl · sg-meter.css | this site's build; the contact file's key is checked against its fingerprint | feed(), openList(), _send() |
| postMessage | replies from the dev.vault.sgraph.ai frames | origin-checked; drives a status line only | vault-ui-embed.js |
Where the reader’s data is kept
One key in this origin’s local storage, sgit.meter.v1, holds all of it; a session key holds the charge per path so a reload does not charge twice. Keys for the share and the subscription exist only in memory while the page encrypts. There is no cookie and no server-side store.
| Field in sgit.meter.v1 | Holds |
|---|---|
v | 3 |
created | ISO time |
balance · spent · declined | pence, to 0.1p |
reads | pages opened |
paused | always false since v1.4.0 |
log[] | ≤ 500 entries: id, at, path, title, kind, topics, price, depth, cost, persona, autoKept?, rating{useful,more,detail,at}?, declined{reason,at,refunded}? |
topups[] | ref (≤ 40 chars), at, credit, source: link | simulated |
sessions[] | every payment reference credited, unbounded |
cart[] | the simulated cart (unused with a live link) |
active | the active persona's id |
personas[] | id, preset, name, created, on[], off[], visitor?, list? (a reading list's key), theme? |
shares[] | ≤ 20: at, how, pages |
What leaves the browser, and external calls
No page loads a script from another origin. These are the ways anything leaves, each tied to something the reader does, except the first.
| What leaves the browser | Carrying | When | Who sees it |
|---|---|---|---|
| GET, own origin | every page, script, style, image and JSON | whenever a page opens | GitHub Pages, which sees the request as any host does |
| POST https://dev.send.sgraph.ai/api/vault/append/write/y9j3nc60 | {append_token, payload}: an .eml encrypted in the browser to the lane's public key | when the reader presses Send on the share page or Subscribe | the vault host; it sees ciphertext, a size and a time |
| Navigation to https://buy.stripe.com/… | the reader leaves for Stripe's page and returns to /account/topped-up.html?session_id=… | when the reader presses Top up / Pay | Stripe (card, email, name, postcode) |
| GET https://dev.send.sgraph.ai/api/vault/read/u9i9jp0l/… | the ciphertext of the wire's vault: its head pointer, commit, tree and the files the page needs, decrypted in the browser with the published read key | when a wire page opens, and when the reader asks for a new commit; the pointer at most every two minutes | the vault server; it sees the request and nothing of the reader |
| iframe https://dev.vault.sgraph.ai/?embed=1 | two frames; the published read key is posted to them | on the study page only | the vault UI host |
| mailto: | Reply to <sender> on a reading list; the subscribe fallback | when pressed; the page sends nothing | the reader's mail client |
| A download | reading-account.json: the whole state, payment references included | Export as JSON | the reader's disk |
| The clipboard | a link, or the share text | Copy | the reader's clipboard |
| postMessage to the parent window | {type:'sg-app-ready'} with targetOrigin * | when a page is framed | whoever framed it |
The contracts
| Contract | Shape | Owner |
|---|---|---|
| The meter config | storageKey, start, newDays, symbol, prices{article_new,article,issue,note,collection,page,free}, depth, minDepth, topup{amount,link}, packs, links{account,topup,how}, rating{price[5],more[5]}, autoKeep, picks{feed,base}, links2{newsroom,personas,share}, share{contact,form,site,max}, personas{presets[],names{}}, root | sg-meter-core.js DEFAULTS; written into every page by sgit.ai's build and re-pointed by the import |
| A charged page | <sg-meter view="page" kind="article|issue|note|collection|page|free" date="…" topics="a b" title="…" [quiet]>: the price class comes from the page | sgit.ai's build for imported pages; page.py (kind=free, quiet) for this site's |
| A persona link | /persona/<id>/ and /persona/#<id>, id ∈ {founder, journalist, security, builder, board} | personas.py, persona/links.json |
| A reading list link | /for/#from=&to=&r=&s=&f=&n=&e=: names ≤ 40 chars of letters, spaces, hyphens, apostrophes; r,s article numbers, 1–50 in all; f persona ids or c:<collection>, ≤ 10; n ≤ 240 chars; e an address | for/reading-list.js LIMITS, parse(), build() |
| Article numbers | articles/ids.json: n and slug are the contract, assigned once in publication order, never changed or reused; every other field is display text | admin/build/ids.py; validate.js check 12 |
| The return page | ?session_id=<ref>: credited once per distinct value, never verified | _toppedUp(), credit() |
| The export | the state as JSON; restore accepts any file with balance:number and log:array | restore() |
| The share | a text summary then the same data as JSON {v:1, site, at, balance, spent, declined, reads, topups, active, personas, topics, cols, log}, as an .eml with X-SGit-Form, X-SGit-Reply-To, X-SGit-Name, X-SGit-Subscribe, X-SGit-Page | shareData(), _send() |
| The envelope | sgit hybrid envelope v2: {v:2, w, i, c} (RSA-OAEP-SHA256 wrapped AES-256-GCM key, iv, ciphertext), base64, then base64 again as the lane payload | envelope(), subscribe.js encrypt() |
| The contact file | sgit-subscribe/v1: recipient{address, fingerprint, encrypt}, inbox{vault, endpoint, encrypt_to, status, lane{name, append_token}}; the fingerprint is recomputed from the PEM before use | .well-known/sgit-subscribe.json |
| The wire's vault | vault u9i9jp0l on dev.send.sgraph.ai, read key published; data/bundle.json {beats, stories, sources, personas, brief}, data/deliveries.json, docs/*.md; story fields and status shown as written; everything in the vault is public, for ever | admin/build/wire-vault.json; wire/how.html; the wire's agents own the content |
| Events | sg:meter.changed · charged · rated · declined · toppedup · reset · restored · persona · shared; sg-meter:error | sg-meter-core.js _emit(), sg-component.js failed() |
| A component | static jsUrl, get resourceName(), onReady(); a .tpl and a .css beside the .js; published at an immutable versioned path | sg-component.js |
| The import's rules | the meter path rewrite (v1.3.1 → v1.4.0), the top-up link, eight text rules, the article number line; each stops the import when its anchor moves | sync_newsroom.py items 9–12 |
The build, the gate and the tests
| Build and gate | What it does | Lines |
|---|---|---|
admin/build/ids.py | assigns article numbers, writes articles/ids.json | 156 |
admin/build/lists.py | writes the two reading-list pages | 103 |
admin/build/personas.py | writes the persona pages and persona/links.json | 160 |
admin/build/page.py | the page template, with the meter block every page this site writes carries | 115 |
admin/build/sync_newsroom.py | the import: re-points the meter to v1.4.0, applies the text rules, prints the article number | 615 |
admin/build/newsroom-meter.json | the top-up link, applied on import | 5 |
admin/build/newsroom-text.json | the eight text rules, applied on import | 135 |
admin/build/wire.py | writes the three wire pages and, from a clone, wire/snapshot.json | 167 |
admin/build/validate.js | the gate: checks 2d (live link), 5 (key tripwire), 11 (imports unedited), 12 (numbers hold) | 465 |
| Test file | What it drives | Check calls | Lines | In CI |
|---|---|---|---|---|
admin/build/test_meter.mjs | the meter in Chromium | 40 | 219 | yes |
admin/build/test_lists.mjs | the parser in Node and the list pages in Chromium | 58 | 214 | yes |
admin/build/test_review.mjs | the review's findings, pinned as the behaviour is today | 14 | 138 | yes |
admin/build/test_passkey.mjs | the passkey lab in Chromium, with a virtual authenticator that has PRF | 31 | 157 | yes |
admin/build/test_wire.mjs | the wire pages in Chromium, reading the real vault | 28 | 126 | yes |
The review
Read line by line on 11 October 2026, as villager code is read: the meter element and core, the component base, the subscribe script, the reading-list code, the loader, the build scripts and the gate. Each finding has its evidence and, where it is a bug, a test in admin/build/test_review.mjs that passes while the behaviour stays as it is, so that the fix is a visible change to the test. Severity is for a reader’s money or data first, the move second. Nothing here was changed in the code, by the brief’s rule: no behaviour changes until phase five. The one exception is R11, which is the pipeline and not the product.
| # | Severity | Area | Finding | Evidence | Pinned by | Proposed change |
|---|---|---|---|---|---|---|
| R1 | medium | the meter's logic | Declining a page as “not relevant” leaves it in the persona's reading list. open() auto-keeps an article on first open; decline() refunds the charge but does not undo the auto-keep, so the article stays under “Its reading list” and profile() adds +5 for it while the decline subtracts 2: a page the reader rejected pulls its topics up. | sg-meter-core.js open() [autoKeep], decline(); seen in a browser on 11 October | test_review.mjs R1 (passes while the article stays in the list) | decline() removes an autoKept entry from the active persona's on[]; a reader who added it by hand keeps it |
| R2 | low | restore from a file | restore() checks that the file has a numeric balance and a log array and nothing else. A persona without on[] and off[] makes the personas and newsroom views throw and show nothing; a log entry's path is rendered as the history link's href as it is; a persona's theme is written into a style attribute as it is. The file is the reader's own, so the exposure is a file someone else sent them to restore. | sg-meter-core.js restore(), _read(); sg-meter.js _account() history rows, _chip() style | test_review.mjs R2 (the blank personas page); the href and theme cases are described, not pinned | normalise on read: personas get arrays, paths must be same-origin paths, themes must be a hex colour; unknown fields dropped |
| R3 | low | the return page | A malformed percent sequence in ?session_id= (for example %E0%A4%A) makes decodeURIComponent throw inside onReady, so the element fails with “did not load: URI malformed” instead of saying the reference is missing. | sg-meter.js _toppedUp() | test_review.mjs R3 | try/catch around the decode; treat an undecodable id as no id |
| R4 | medium | data correctness | A reading share or a subscription sent from this site is labelled sgit.ai: the imported config has share.site = "sgit.ai" (so the text begins “My reading on sgit.ai”), subscribe.js writes From: site@sgit.ai, and the contact file says site: sgit.ai. The agent that drains the lane can tell the two sites apart only by the Sent from line. | index.html sg-meter-config share.site; assets/subscribe.js eml(); .well-known/sgit-subscribe.json | test_review.mjs R4 | a recorded meter rule for share.site (admin/build/newsroom-meter.json); subscribe.js and the contact file are verbatim imports and need this site's own copies, or a parameter, after the move |
| R5 | note | operations | On the live site the lane for shares and subscriptions is on dev.send.sgraph.ai and the study page's vault frames come from dev.vault.sgraph.ai. Both hosts are named “dev”. Production reader data goes to them. | .well-known/sgit-subscribe.json inbox.endpoint; assets/vault-ui-embed.js ORIGIN | test_review.mjs R5 (records the hosts) | a decision, not a code change: either these are the production hosts and say so, or the files point at production ones |
| R6 | low | display | Money is kept to a tenth of a penny and shown to a penny. Balance and spent are each formatted on their own, so a half-penny state shows, from £5.00, a balance of £4.95 and £0.06 spent, which do not add up; the history shows 5.5p. A reader who checks the arithmetic finds a penny missing. | sg-meter-core.js money(), r1(); seen with balance 494.5 | test_review.mjs R6 | one rounding rule for the pair (format spent as start − balance, or keep both to 0.1p on the account page) |
| R7 | question | picks | picks() excludes every page in the log, whichever persona read it, while profile() is per persona. An article read as Morgan is never picked for Rowan. | sg-meter-core.js picks() | test_review.mjs R7 (records the behaviour) | decide: per-persona exclusion, or keep and say so on the newsroom page |
| R8 | low | robustness | feed() caches a failed fetch as null for the life of the page, so one transient failure leaves “The list of articles could not be loaded” on every view until a reload. | sg-meter-core.js feed() | test_review.mjs R8 | do not cache a failure; retry on the next call |
| R9 | medium | the number contract | If sgit.ai renames an article (a new slug), ids.py marks the old number withdrawn and gives the article a new number. A reading list that named the old number then says the article is no longer published, although it is. | admin/build/ids.py assign() | described; a rename has not happened yet | a renamed_to field on the old entry, followed by the list page; the gate requires it when a slug disappears and a new one appears in the same import |
| R10 | high for the move | Content-Security-Policy | Every imported page loads assets/site.js, subscribe.js and shots.js by fetching the text and running it with (0, eval)(text) (the loader's bridge path), and the loader and the meter config are inline. A script-src policy without 'unsafe-eval' and without hashes breaks every page. frame-ancestors cannot be set in a meta tag at all, and GitHub Pages sets no headers, so framing (the Keep, Remove and Top up buttons under another site) is not prevented today. | the loader IIFE on every imported page; subscribe/index.html | test_review.mjs R10 (counts the pages that eval) | load scripts with <script src> or import(); then a meta CSP for script-src and connect-src; frame-ancestors needs a host that sets headers |
| R11 | high per the brief | the pipeline | The one workflow ran validate.js only; the browser tests (46 + 65 checks) never ran in CI. | .github/workflows/deploy-pages.yml | fixed in this release: a reader-tests job runs all three test files before the tag | done; the move's pipeline adds WebKit and Firefox and a pre-production copy |
| R12 | note for the move | two upstreams | The account pages, subscribe, the contact file and the component base are imported from sgit.ai on every sync and cannot be changed here except by a recorded rule, and verbatim files (scripts, JSON) cannot be changed by rule at all. R4 is the first consequence. The newsroom's meter is v1.4.0 while sgit.ai's is v1.3.1. | sync_newsroom.py VERBATIM, MOVED_DIRS | the brief's phase 1 removes it | the move: both sites pin released files |
| R13 | note | what leaves | The export includes the payment references (sessions, topups[].ref) that the share deliberately leaves out. A reader who sends their export instead of their share sends them. | sg-meter.js _export(); shareData() | described | say so beside the button, or export without sessions |
| R14 | low | bounds | sessions[] and topups[] grow without bound, and a payment reference is not checked for shape before it is stored. | sg-meter-core.js credit() | described | cap both; keep references that look like cs_… only |
| R15 | low | personas | persona(id) falls back to personas[0], which is “me” only when “me” is first; after a restore that puts another persona first, an unknown id resolves to that persona. | sg-meter-core.js persona(), _read() | described | fall back to the persona with id me |
| R16 | process | caching | Imported pages load the meter as sg-meter.js?v=<sgit.ai's release>, so the cache-buster never changes for a release of this site. That is fine while a published version is never edited in place, which is the rule; it is written here because v1.4.0 was edited in place while it was being built. | the import's own_meter() keeps sgit.ai's ?v= | described | a change is a new version path, always |
| R17 | note | reading lists | A list's persona is keyed by a 32-bit hash of the names and numbers, so two different lists can share one persona with probability about one in four billion per pair. | for/reading-list.js listKey() | described | a longer hash, or the fragment itself as the key |
| R18 | note | listeners | The landing view adds a window hashchange listener per element and never removes it, and re-renders on every meter change (the picker's “Copied” label resets). | sg-meter.js _landing() | described | one listener, removed on disconnect |
Accepted, and said so
| Accepted gap | Why | Where it is said |
|---|---|---|
| The return page credits any session id | a static page cannot verify a payment; building the address by hand credits the reader's own screen | meter/security.html |
| The reader can edit their own balance | the state is in their storage; nothing is behind a wall | meter/security.html, No server by design |
| The names in a reading list are whatever the link says | there is no signature yet; a signed list is a later step on RFC 0001's keys | for/index.html |
| A page is charged by the deepest point reached, and the reader card is at the foot | reaching the card to rate a page is reading to the end | going-live-with-the-reading-meter.html |
What the move takes from here
- The before-hashes in the file and page tables, to be compared with the live site after the move with the same normalisation.
- The controls and inputs tables as the checklist for the browser tests in three engines, and for the adversarial exercise: change what a reader sees or pays without a test failing.
- The contracts table as the README of the new repository: these are the things that must not change in a pure refactoring.
- The findings, one issue each, labelled with the phase they belong to; R10 and R12 are the shape of the work, the rest are its first items.
For an agent
This page maps the client-side API of newsroom.sgit.ai at v0.9.3: every reader file with its hash, every view and control, every input, what leaves the browser, the contracts, and a review with 18 findings. The same data is at https://newsroom.sgit.ai/newsroom-api/map.json. It is regenerated by admin/build/api_map.py on each build; the findings are pinned by admin/build/test_review.mjs. The brief it answers is https://newsroom.sgit.ai/articles/explorer-to-villager-newsroom.html.