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.

What the hashes are for. The brief’s rule for the move is that the live site serves the same bytes after it as before, proved by hash and not by reading the diff. The hashes below are that “before”: scripts, styles and JSON raw; pages with the site chrome and version stamps normalised, as the gate already does for imported pages. They change when the files change, and they are meant to.

In numbers

Reader code that runs in a browser3,344 lines in 16 files; 5,697 more lines in 20 frozen files of earlier meter versions, served and never changed
Pages that carry it21 pages written for it; the balance chip and a charged-page element on every page of the site
Views, controls, inputs12 views of one element, 56 kinds of control, 11 ways data comes in, 9 ways it goes out
Contracts14, listed below; the two that other sites and agents already program against are the reading-list link and articles/ids.json
Tests171 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-Policy0 (R10)
Upstreamstwo: 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.

FileWhat it isFromLinesReachesSHA-256 (normalised for pages)
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter.jsSG Meter, the element: twelve views, the reader card, the share form, the graphthis site, from sgit.ai's v1.3.18152 fetch, sg:meter.changed, confirm(), WebCrypto82f8c09842290336
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter-core.jsSG Meter, the meter: prices, the charge, ratings, declining, credit, personas, picks, the share textthis site, from sgit.ai's v1.3.15111 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.toppedup6a74b1183c766d5a
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter.cssSG Meter styles, adopted into the shadow rootthis site, from sgit.ai's v1.3.1188—98a8640466813589
assets/components/sg-meter/v1/v1.4/v1.4.0/sg-meter.tplSG Meter markup (one div)this site, from sgit.ai's v1.3.11—104c7c1ded976408
assets/components/base/v1/v1.0/v1.0.0/sg-component.jsThe component base: fetches the .tpl and .css beside a component and calls onReady()sgit.ai, verbatim1181 fetch, sg-meter:error, innerHTMLe566a78ad98fbd18
for/reading-list.jsA reading list in a link: parse, build, show, open as a personathis site3431 fetch5f8fa1378b99c24c
for/make.jsThe page that makes a reading list and previews itthis site2541 fetchec9abc1f40b5f564
for/for.cssStyles for the two reading-list pagesthis site109—cca712d5810b3085
assets/subscribe.jsThe subscribe form: encrypts the address and posts it to the lanesgit.ai, verbatim1172 fetch, WebCryptofe2eec58c28f15e5
wire/vault-reader.jsReads an sgit vault in the browser with a published read key: ref, commit, tree, blobs, decrypted with Web Crypto; the markdown rendererthis site, vendored from sgit.ai's vault-docs.js1821 fetch, WebCrypto332509b6be44538d
wire/wire.jsThe Industry Wire: the edition with its lens, the sources, the documents, drawn from the vault as textthis site214localStorage, innerHTML91289d4ad810fcd6
wire/wire.cssStyles for the wire pagesthis site76—51fc706e9725f4b6
admin/build/wire-vault.jsonThe wire's vault: endpoint, vault id, the public read key (the only key on this site)this site14—c8c84bea5a65df09
wire/snapshot.jsonThe wire as it was in the vault at the last release: headlines and primary links, the fallback and the agents' indexthis site, generated412—e3adb1150066e6e9
assets/vault-ui-embed.jsThe study page: embeds the vault browser from dev.vault.sgraph.ai and hands it a read keysgit.ai, verbatim126hosts: dev.vault.sgraph.ai, innerHTML308af99563423f5e
assets/shots.jsFigures: sets each image's address at runtimesgit.ai, verbatim1671 fetch3e6d8149ae48e24e
assets/site.jsPage behaviour: nav, copy buttons, reveal-on-scrollsgit.ai, verbatim81—0069c69190c8afc1
assets/nav.jsNav behaviour on the pages this site writesthis site42—bf18958cd99e10b5
.well-known/sgit-subscribe.jsonThe lane and the public key the share and the subscription are encrypted to and written intosgit.ai, verbatim25hosts: dev.send.sgraph.ai1414b8bb22c50b86
articles/ids.jsonA number for every article: the contract reading lists are built onthis site, generated1,246—9bf4b1222af05d71
persona/links.jsonThe persona links, for whoever writes to someonethis site, generated268—7b7bc6c112744e50
articles/graphs.jsonThe feed the picks and the graph read: every article with topics, tags and citationssgit.ai, imported18,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.

FileWhat it isLinesSHA-256
passkey/lab.jsThe passkey lab: WebAuthn with PRF, the door derivation, sgit's key derivation, a stand-alone vault client4134c609fa85e041be3
passkey/lab.cssits styles28d6a2b51445e89ca9
passkey/vectors.jsonits test vectors, from sgit's Python19cfebdc0fe8d139ae
passkey/index.htmlthe page2972734d500402f16ee

The pages

PageWhatLinesSHA-256, chrome and stamps normalised
account/index.htmlthe reading account250fbe19375c9e0b1e9
account/newsroom.htmlthe reader's newsroom24525ce5685c477f0fc
account/personas.htmlthe personas245645413ccdf42b949
account/share.htmlsend us your reading247f75680bc60808188
account/top-up.htmlthe top-up245ea1f9348b198c8fb
account/topped-up.htmlthe page a payment returns to244518f95ea18b117b4
subscribe/index.htmlsubscribe265c2027e60d91dbc82
persona/index.htmlthe persona picker227eb1fb60d66f1bc01
persona/founder/index.htmla persona link2347f1158881c4b5708
persona/journalist/index.htmla persona link2345e7abe06b002715d
persona/security/index.htmla persona link234ef7b2f455eb26938
persona/builder/index.htmla persona link234531be95b48cd5e11
persona/board/index.htmla persona link2349db951fc6bd34999
for/index.htmla reading list241313dac3b3f31ae29
for/make.htmlmake a reading list2313c827cd696b202f3
wire/index.htmlthe Industry Wire's edition56ebb2c8b658df44fc
wire/sources.htmlthe wire's sources529e4d5619a961370c
wire/how.htmlhow the wire is read, and its documents56f331d73c149731c1
meter/index.htmlSG Meter, documented386a55c58061932dc93
meter/security.htmlthe meter's security model271aa41ecfcbd9cd211
demos/vaults/newsroom-synthetic-users/index.htmlthe synthetic-readers study, with the embedded vault3320a2a202f29029b4f

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)

ControlLabelWhat it does
linkthe balanceopens the account page

page · every charged page, at the foot

ControlLabelWhat it does
linkHow paying for reading works hereopens links.how
linkBalanceopens the account page
button+ Add to your reading list / ✓ In your reading listmeter.curate(slug, 'on' | '')
5 buttons × 3How useful was this? · More like this? · Level of detailmeter.rate(field, 1–5); usefulness re-prices the page
linkYour newsroomopens links2.newsroom
buttonSomething wrong with this page? Don’t charge meshows the four reasons
4 buttonsthe reasonsmeter.decline(reason): refunds the page

account · account/index.html

ControlLabelWhat it does
linkTop up £5.00navigates to the Stripe payment link (topup.link)
buttonExport as JSONdownloads reading-account.json (the whole state)
linksYour newsroom · Your personas · Send us your readingown pages
linksone per history rowthe page read, from the log's own path
file inputRestore from an export…confirm(), then meter.restore(text)

topup · account/top-up.html

ControlLabelWhat it does
linkPay £5.00navigates to the Stripe payment link; with no link, the simulated cart

topped-up · account/topped-up.html

ControlLabelWhat it does
on load?session_id=…meter.credit(amount, 'link', id), once per id, unverified
linkYour account

picks · the front page

ControlLabelWhat it does
linksone per pickopens the article

newsroom · account/newsroom.html, and under a persona banner

ControlLabelWhat it does
buttonsthe persona chipsmeter.setActive(id)
link+ Add or manage personasthe personas page
linkA newsroom designed for youthe share page (not when compact)
2 buttons per pickAdd to reading list · Not for this personameter.curate(slug, 'on' | 'off', active)
button per kept / put-out articleremove · bring backmeter.curate(slug, '')
buttonsAdd this persona (per preset) · Add a blank personameter.addPersona(preset | '')

personas · account/personas.html

ControlLabelWhat it does
input + button per personaname · Renamemeter.renamePersona(id, name)
button per personaMake activemeter.setActive(id)
link per personaOpen its newsroomsetActive, then the newsroom page
button per personaRemoveconfirm(), then meter.removePersona(id)
buttonsthe preset cardsmeter.addPersona

graph · account and newsroom pages

ControlLabelWhat it does
linksone per article dotopens the article

link · pages that carry it

ControlLabelWhat it does
linkYour newsroom

landing · persona/index.html and persona/<id>/

ControlLabelWhat it does
on loadthe persona named by the attribute or the #fragmentmeter.visit(id): added as a visitor, made active, nothing charged
buttonsKeep this persona · Remove itmeter.keepPersona(id) · meter.removePersona(id)
buttonRead as <name> againmeter.visit(id) after a removal
linksYour personas · Other personasown pages
per preset (the picker)Read as this persona · Copy linkopens persona/<id>/ · clipboard

share · account/share.html

ControlLabelWhat it does
buttonCopy to clipboard · Copy it insteadclipboard; meter.noteShared('copied')
inputsname · email (required) · note · consent · newsletter · a hidden honeypotthe form; nothing is sent without consent
submitSend it, encryptedfetch the contact file, verify the key's fingerprint, encrypt an .eml, POST to the lane

reading list (for/) · for/index.html

ControlLabelWhat it does
on loadthe fields after the #parse(); visitList(): a visitor persona named for the two people, its articles as the reading list
linkStart readingthe first article
linkReply to <sender>mailto:, only when the link carries e=
buttonsKeep this list as a persona · Remove it · Add it againmeter.keepPersona · meter.removePersona · visitList
linksevery article, every persona or collection to followown pages

make a reading list · for/make.html

ControlLabelWhat it does
inputsFrom · To · a note · your emailvalidated as the recipient's page validates them
search and two selectstitle, topic or number · topic · personafilters the article list
2 buttons per articleRead · Lookputs the article in a group
per chosen article↑ · ↓ · To look / To read · Remove, and dragorders the list
checkboxespersonas and collections to follow
buttonCopy linkclipboard
linkOpen as the recipientfor/#… in a new tab
input + buttonEdit an existing link · Loadparse() into the form
buttonClear the list

subscribe form · subscribe/index.html

ControlLabelWhat it does
inputsemail · name · consent · a hidden honeypot
submitSubscribefetch 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/

ControlLabelWhat it does
on loadtwo iframes from dev.vault.sgraph.aithe published read key is handed over postMessage; the vault is decrypted in the frame

article foot · every article

ControlLabelWhat it does
linkstart a reading list with itfor/make.html#r=<number>

Inputs

Where data comes inWhatWho controls it, and how it is checkedIn the code
Element attributesview · kind · date · topics · title · quiet · count · persona · compactthe 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 defaultsg-meter-core.js _config()
The address: query?session_id= on account/topped-up.htmlwhoever 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.htmlwhoever made the link; validated field by field, the rest dropped and listed_named(), reading-list.js parse()
The address: pathlocation.pathnamethe charge is keyed on it; the slug is taken from it; the share records itopen(), _page(), _send()
Local storagesgit.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 fileRestore from an export…whoever made the file; checked for balance:number and log:array onlyrestore()
Formsthe share form, the subscribe form, the make pagethe reader; email checked by a loose pattern, names by a strict one_send(), subscribe.js, make.js
The wire's vaultdata/bundle.json, data/deliveries.json, docs/*.md, decrypted in the browserwhoever 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 usedwire/wire.js
Own-origin dataarticles/graphs.json · articles/ids.json · .well-known/sgit-subscribe.json · sg-meter.tpl · sg-meter.cssthis site's build; the contact file's key is checked against its fingerprintfeed(), openList(), _send()
postMessagereplies from the dev.vault.sgraph.ai framesorigin-checked; drives a status line onlyvault-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.v1Holds
v3
createdISO time
balance · spent · declinedpence, to 0.1p
readspages opened
pausedalways 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)
activethe 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 browserCarryingWhenWho sees it
GET, own originevery page, script, style, image and JSONwhenever a page opensGitHub 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 keywhen the reader presses Send on the share page or Subscribethe 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 / PayStripe (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 keywhen a wire page opens, and when the reader asks for a new commit; the pointer at most every two minutesthe vault server; it sees the request and nothing of the reader
iframe https://dev.vault.sgraph.ai/?embed=1two frames; the published read key is posted to themon the study page onlythe vault UI host
mailto:Reply to <sender> on a reading list; the subscribe fallbackwhen pressed; the page sends nothingthe reader's mail client
A downloadreading-account.json: the whole state, payment references includedExport as JSONthe reader's disk
The clipboarda link, or the share textCopythe reader's clipboard
postMessage to the parent window{type:'sg-app-ready'} with targetOrigin *when a page is framedwhoever framed it

The contracts

ContractShapeOwner
The meter configstorageKey, 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{}}, rootsg-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 pagesgit.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 addressfor/reading-list.js LIMITS, parse(), build()
Article numbersarticles/ids.json: n and slug are the contract, assigned once in publication order, never changed or reused; every other field is display textadmin/build/ids.py; validate.js check 12
The return page?session_id=<ref>: credited once per distinct value, never verified_toppedUp(), credit()
The exportthe state as JSON; restore accepts any file with balance:number and log:arrayrestore()
The sharea 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-PageshareData(), _send()
The envelopesgit 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 payloadenvelope(), subscribe.js encrypt()
The contact filesgit-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 vaultvault 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 everadmin/build/wire-vault.json; wire/how.html; the wire's agents own the content
Eventssg:meter.changed · charged · rated · declined · toppedup · reset · restored · persona · shared; sg-meter:errorsg-meter-core.js _emit(), sg-component.js failed()
A componentstatic jsUrl, get resourceName(), onReady(); a .tpl and a .css beside the .js; published at an immutable versioned pathsg-component.js
The import's rulesthe 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 movessync_newsroom.py items 9–12

The build, the gate and the tests

Build and gateWhat it doesLines
admin/build/ids.pyassigns article numbers, writes articles/ids.json156
admin/build/lists.pywrites the two reading-list pages103
admin/build/personas.pywrites the persona pages and persona/links.json160
admin/build/page.pythe page template, with the meter block every page this site writes carries115
admin/build/sync_newsroom.pythe import: re-points the meter to v1.4.0, applies the text rules, prints the article number615
admin/build/newsroom-meter.jsonthe top-up link, applied on import5
admin/build/newsroom-text.jsonthe eight text rules, applied on import135
admin/build/wire.pywrites the three wire pages and, from a clone, wire/snapshot.json167
admin/build/validate.jsthe gate: checks 2d (live link), 5 (key tripwire), 11 (imports unedited), 12 (numbers hold)465
Test fileWhat it drivesCheck callsLinesIn CI
admin/build/test_meter.mjsthe meter in Chromium40219yes
admin/build/test_lists.mjsthe parser in Node and the list pages in Chromium58214yes
admin/build/test_review.mjsthe review's findings, pinned as the behaviour is today14138yes
admin/build/test_passkey.mjsthe passkey lab in Chromium, with a virtual authenticator that has PRF31157yes
admin/build/test_wire.mjsthe wire pages in Chromium, reading the real vault28126yes

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.

#SeverityAreaFindingEvidencePinned byProposed change
R1mediumthe meter's logicDeclining 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 Octobertest_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
R2lowrestore from a filerestore() 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() styletest_review.mjs R2 (the blank personas page); the href and theme cases are described, not pinnednormalise on read: personas get arrays, paths must be same-origin paths, themes must be a hex colour; unknown fields dropped
R3lowthe return pageA 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 R3try/catch around the decode; treat an undecodable id as no id
R4mediumdata correctnessA 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.jsontest_review.mjs R4a 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
R5noteoperationsOn 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 ORIGINtest_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
R6lowdisplayMoney 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.5test_review.mjs R6one rounding rule for the pair (format spent as start − balance, or keep both to 0.1p on the account page)
R7questionpickspicks() 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
R8lowrobustnessfeed() 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 R8do not cache a failure; retry on the next call
R9mediumthe number contractIf 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 yeta 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
R10high for the moveContent-Security-PolicyEvery 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.htmltest_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
R11high per the briefthe pipelineThe one workflow ran validate.js only; the browser tests (46 + 65 checks) never ran in CI..github/workflows/deploy-pages.ymlfixed in this release: a reader-tests job runs all three test files before the tagdone; the move's pipeline adds WebKit and Firefox and a pre-production copy
R12note for the movetwo upstreamsThe 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_DIRSthe brief's phase 1 removes itthe move: both sites pin released files
R13notewhat leavesThe 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()describedsay so beside the button, or export without sessions
R14lowboundssessions[] and topups[] grow without bound, and a payment reference is not checked for shape before it is stored.sg-meter-core.js credit()describedcap both; keep references that look like cs_… only
R15lowpersonaspersona(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()describedfall back to the persona with id me
R16processcachingImported 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=describeda change is a new version path, always
R17notereading listsA 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()describeda longer hash, or the fragment itself as the key
R18notelistenersThe 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()describedone listener, removed on disconnect

Accepted, and said so

Accepted gapWhyWhere it is said
The return page credits any session ida static page cannot verify a payment; building the address by hand credits the reader's own screenmeter/security.html
The reader can edit their own balancethe state is in their storage; nothing is behind a wallmeter/security.html, No server by design
The names in a reading list are whatever the link saysthere is no signature yet; a signed list is a later step on RFC 0001's keysfor/index.html
A page is charged by the deepest point reached, and the reader card is at the footreaching the card to rate a page is reading to the endgoing-live-with-the-reading-meter.html

What the move takes from here

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.