Skip to content

Releases: danjamk/knag

v1.12.0 — put a restored line back in its own section

Choose a tag to compare

@danjamk danjamk released this 07 Sep 13:41
9860d3c

A line put back from history returns to the section it left from.

Added

  • # marks a section header (#250). One to six # then a space; the space is required, so #1 priority and #todo stay ordinary text, and a # inside a fence is never classified because a fence is one block.

    🔴 Nothing renders it. The marker stays on screen at the same size and weight as every other line — ADR-004 asks that the file be reconstructable byte-for-byte from what is displayed, and a styled heading with the # eaten is not. A header is a position, not a style. It is not an index and never becomes one: no jump-to-section, no collapse, no list of headers. That is the Out list arriving by the side door.

  • A restored line lands under its own header. Tapping a line in the history pane used to append it to the bottom of the page — insertLines ended in push and had no position at all. On a page kept in sections that meant re-sorting every restore by hand, and the cost was paid at the moment the wipe was being considered.

    It lands after the last line of its section rather than against the next header: a section usually ends with a blank separating it from the next, and inserting at the boundary put the line somewhere that read as belonging to neither.

Changed

  • The section is computed at read time from the sealed pre-wipe body, which is the sealing entry's own body. No migration, no column — and a wipe recorded before this release still answers. cleared rows carry section; the entry after a seal carries disappeared_sections, one per line in the same order. Both are absent when the wiped page had no headers, so every page that has never used one carries exactly the payload it always did.

  • The header is matched on its exact bytes, like every other anchor in restore.ts. A header edited since the wipe is a different header, and guessing that # RealPlus and # Real Plus are the same one would put a line under a name nobody wrote. A line whose header has gone lands at the end — content over position.

  • The MCP server instructions and knag_history's schema both name the new fields. An output schema drops keys it does not declare, so a field added to the shared payload is invisible through MCP until it is declared. There is a test for that now.

Note

The wipe still takes headers, like any other line. A page that wants its skeleton back puts the headers in its template, which a whole-page wipe lays down. Teaching the wipe to leave headers standing on its own is a separate decision and is not made here.

Full changelog: v1.11.0...v1.12.0

v1.11.0 — annotate a past history entry, append-only

Choose a tag to compare

@danjamk danjamk released this 07 Sep 12:44
e2085bd

A past history entry can be annotated. It still cannot be changed.

Added

  • knag_annotate(entry_id, note, page?) (#253), the fifth MCP tool and the first write in this server that is not a whole-page write. Details surface after the wipe — watch data syncs late, an intensity rating gets reconsidered, a substitution is remembered an hour on — and until now the choice was to lose them or keep a shadow record elsewhere, which defeats the point of the wipe history being the only log.

    🔴 Append-only, and that is the feature rather than a first cut. There is no edit path and no delete path — not in the tool list, and not in store.ts, which has an insert and a read and nothing else. The record is worth something because it is evidence of what happened; a tool that can rewrite a past snapshot lets a bad session be quietly laundered into a good one with nothing downstream able to tell. A correction is another note. Tests assert both the behaviour and the absence.

    Deliberately not in scope: setting a header field on a past entry as a typed value (intensity=4). That is editing wearing a costume, and it gets its own feature with its own provenance if it turns out to be needed.

  • annotations on a history entry, oldest first so a correction reads after the thing it corrects. Beside the entry, never merged into it. Both surfaces carry them — unlike snapshot, the browser will need these.

  • Migration 0014, additive. No owner_id on the table: an annotation belongs to a revision, a revision to a page, and a page to an owner, so the owner is reachable by join. Denormalizing it would put the answer in two places, which is the shape of the bug where one person reads another's page.

Changed

  • The server instructions said "whole-page write is the only write", and this release made that false. Corrected in the same change — a rule an agent reads and acts on is worth more than one release of tidiness.

  • deleteUserHard takes annotations with everything else a person owns. Append-only holds while the record is theirs to keep; deletion on request is not an edit to it.

Not in this release

The history pane does not show annotations yet — this is the server half. How a note reads against a wiped line is a visual decision and goes to the design session first (#257). Until that lands, a note added through the connector is readable through the connector and not in the app.

Note: a connector re-reads the tool list on reconnect, so knag_annotate appears at the next handshake rather than at deploy.

Full changelog: v1.10.0...v1.11.0

v1.10.0 — carry the wiped page on its history entry

Choose a tag to compare

@danjamk danjamk released this 06 Sep 16:15
fea1388

A wipe's entry in knag_history now carries the page it wiped.

Added

  • snapshot on a wipe's sealing entry (#252). The removed lines were always recoverable from the following entry's disappeared — 1.9.4 made the description say so — but that reconstruction has a blind spot the code has always documented: a line diff is a set difference, so a page holding the same line twice and losing one of them reports nothing. For a page kept as the only record of something, a reconstruction with a known gap is not the same as the bytes.

    The sealing entry's body already is the pre-wipe page — that is why its own diff is empty by construction — so this exposes what was there rather than storing anything new. No migration, no extra query.

  • snapshot_truncated, and a 16 KiB cap. A document may legally be a megabyte, and a week of daily wipes would put seven of those in one response. 🔴 The cut is at a line boundary, never mid-line: nothing is normalized in this product, so a snapshot is worth having because its lines are the bytes that were there, and a byte slice would hand back a final line that was never on the page and is indistinguishable from one that was.

Changed

  • loadHistory takes snapshots and only knag_history passes it. The history pane never reads a snapshot and the phone fetches that payload on every open, so the browser's response is the shape it always was. An opt-in argument rather than a second code path: one implementation behind both surfaces is what the MCP/HTTP parity test exists to keep.

  • The tool description names the new field, for the reason 1.9.4 exists — a field nothing points at is a field an agent concludes is missing. spec §5 and §14.6 updated: §14.6 had said the MCP shape was identical to GET /api/history, and it is not any more.

Note: a connector re-reads tool descriptions and schemas on reconnect, so this reaches claude.ai at the next handshake rather than at deploy.

Full changelog: v1.9.5...v1.10.0

v1.9.5 — drop the settings table

Choose a tag to compare

@danjamk danjamk released this 06 Sep 15:31
36e5e6c

settings is dropped. The three-release move that started in 1.7.0 is finished.

Changed

  • Migration 0013 drops the legacy settings table (#234, release three of three). 0010 created user_settings and wrote both; 1.9.0 stopped writing the old one and carried no migration at all, which is the release that makes this one safe and the one that looked skippable. Nothing in store.ts has read or written settings since.

    🔴 The ordering is the whole point (ADR-002 §3): make migrate runs before make deploy, so the Worker live while the drop executes is the previous release. That Worker has to already be a non-writer, and both environments are. Skip the middle release and the drop lands under a live writer, and nothing fails loudly — the writes just start erroring against the only copy of the setting.

  • The guard in users-scoping.test.ts changed shape rather than going away. It used to select from settings and assert no row came back; it now asserts the table is absent from sqlite_master, which is the stronger question — a mirror write that came back would fail against a missing table instead of quietly repopulating one.

  • Three stale comments and one spec line fixed: deleteUserHard's doc listed "settings" where it deletes user_settings, writeSetting's header described itself as release two, and spec §14 still named settings as where the operator's agent text lives.

Note for the prod deploy

This is the first destructive migration knag has shipped. Both workflows back up D1 as their first step, so the backup happens before the migration runs. Verified on dev after deploy: settings is gone, user_settings and every other table intact.

Full changelog: v1.9.4...v1.9.5

v1.9.4 — say where a wipe's removed lines are

Choose a tag to compare

@danjamk danjamk released this 06 Sep 12:38
249378a

The history tool stops sending agents to the wrong field.

Fixed

  • knag_history never said where a wipe's removed lines are (#251). Its description ended on "it snapshots the page as it stood before, so its own diff is empty by construction and the cleared lines are the record" — which points a reader at cleared, a checked-only set, as though it were the contents of the wiped page, and stops exactly where the useful half begins. It never said the lines are on the entry after the one that seals the wipe.

    An agent read it the way it was written, found 19 checked lines where the page had held 29, and reported a workout's notes and intensity rating as lost. They were in the same response, on the following entry's disappeared. Nothing had been lost and nothing was broken: the tool described itself badly enough to talk a reader out of data it was handing over.

    The description now states the shape — a wipe writes two entries on one timestamp, the removed lines are the second one's disappeared, cleared answers what was finished and its revision_id names the seal rather than the entry carrying the lines. Entries are addressed by id, since local_time is not unique by design. The output schema says the same things field by field, for a reader that never sees the prose.

    No data, no shape, no route changed. spec §5 already said "reading cleared_items instead would silently drop the unfinished lines", and the client's own toDays carries the pairing rule in a comment. The MCP consumer was the one surface never told.

    Two tests pin it — one on the description's claims, one on the shape those claims describe — because prose is the entire fix and nothing else fails when it drifts.

Note: a connector re-reads tool descriptions on reconnect, so this reaches claude.ai at the next handshake rather than at deploy.

Full changelog: v1.9.3...v1.9.4

v1.9.3 — a blank KNAG_ENV is unknown, never local

Choose a tag to compare

@danjamk danjamk released this 06 Sep 12:08
74d9a85

A deploy that skipped the --var flags believed it was running on a laptop, and a laptop is where knag prints login codes to the log.

Fixed

  • A blank KNAG_ENV read as local (#248). The var is baked at deploy by --var, which only the Makefile and the two deploy workflows pass; a plain wrangler deploy shipped the committed default, which was "". Every reader spelled the fallback KNAG_ENV || "local", so such a deploy would take sendMail's local branch — and that branch writes the six-digit login code into the log, the one thing ADR-008 §9 says must never reach a real deployment's observability. It also reported local at /health, the field that exists so a deploy to the wrong environment can be told from one that failed.

    Blank now fails closed: envName() answers unknown, the code is withheld, and the misconfiguration is logged as one. local became a value the local entry points pass out loud — pnpm dev and scripts/dev-lan.sh both send --var KNAG_ENV:local.

    Nothing in dev or prod was affected. Both workflows bake the var, so neither has ever shipped a blank one. It was reachable only by a deploy nothing performs today — which is exactly what a Deploy to Cloudflare button would perform (#247).

Changed

  • The committed defaults name an environment instead of nothing: selfhost at the top level, prod under env.prod.
  • selfhost wears the prod tile and no subject-line tag. Both marks exist to tell two installs apart, and a self-hosted knag is somebody's only one. local keeps the mark — dev-lan on a phone sits beside prod on the same home screen.

Full changelog: v1.9.2...v1.9.3

1.9.2 — the checkbox shortcut, and backups off the public repo

Choose a tag to compare

@danjamk danjamk released this 31 Aug 13:07
80536bd

The checkbox shortcut works on the devices it is used on, and the backups are not on a
public repository any more.

Fixed

  • -- did not become a checkbox on a Mac or an iPhone, and had not since 1.0.0
    (#242). Autocorrect is on in the editing surface deliberately — ADR-003 §6, restored in
    #112 when CodeMirror arrived — and Apple's autocorrect includes smart dashes, which
    rewrite two hyphens into – or — while you type. The space landed after the hyphens
    were already gone, so a shorthand matching a literal -- never fired. It now accepts
    what the platform actually produces, and takes the caret offset from the match's own
    width rather than a constant, because — is two characters where -- is three.

    🔴 No browser test can cover this and none was written. Substitution happens in the
    OS input stack, above WebKit — Playwright types characters straight into the page, so a
    browser test types a literal -- forever and would agree with the bug. Found from use,
    seven months of releases after it broke.

  • 🔴 Production backups were being written to a public repository. Every prod deploy
    since 2026-08-18 uploaded a cleartext dump of every page belonging to every person using
    the deployment as a GitHub Actions artifact, and the scheduled backup added one
    nightly. The artifact list is readable with no authentication at all, and downloading
    needs only read access to a repo that grants it to everyone. Twenty-eight dumps were
    live; they were retrieved and deleted, and both workflows now write to a private R2
    bucket
    in the production account.

    R2 rather than anywhere else for a reason beyond privacy: it is what lets a laptop hold
    a copy without holding a dangerous credential. wrangler d1 export is a POST that
    creates a job, so pulling a backup straight out of D1 needs D1: Edit on production.
    Reading an object needs R2: Read, which can do nothing but read. The job writes;
    make backup-pull reads.

  • The Quick start did not reach a logged-in app. pnpm dev passed no
    KNAG_OPERATOR_EMAIL, so a fresh clone got a running Worker and a login screen it could
    not pass.

  • Documentation that had become wrong, found by an audit of the repo as a public
    project: the README described one page and no history pane; docs/deployment.md omitted
    the two zone-scoped permissions the prod token needs — which fails a deploy after the
    migrations have applied — and still described production as unprovisioned; the Makefile
    said destructive changes take two releases where ADR-002 §3 says three; ADR-005 gated
    consent on a passphrase that no longer exists and named an endpoint path that returns
    the PWA shell. Two references to a private sibling repository, missed by an earlier
    sweep, are gone.

Added

  • A scheduled production backup (#233) — nightly, to R2, failing on an empty export
    rather than going green with a schema and no rows.
  • make backup-pull — fetch a production backup onto this machine with a read-only
    token.
  • Two README sections a hosted person needs: how to host knag for other people — the
    seven things a fork must replace, including the KV namespace no document mentioned — and
    what the operator can see, which says the thing the admin view's no page content, ever
    does not: the operator holds the database, and every backup is a plain dump of everyone's
    pages.
  • What actually protects the data, in docs/deployment.md. 🔴 Including the number
    that was wrong everywhere: D1 Time Travel is 7 days on the free plan, not 30, so
    everything older than a week rests entirely on the backup.

Full changelog: v1.9.1...v1.9.2

1.9.1 — the default page is the server's answer

Choose a tag to compare

@danjamk danjamk released this 30 Aug 23:54
9f25aec

Anyone who is not the operator can now open the app.

Fixed

  • The client fell back to page 1, which is the operator's page (#240). app.ts kept a
    DEFAULT_PAGE_ID = 1 and used it as the page to fall back to — a constant that #230
    made wrong without making it fail, because the default page is defaultPageFor(owner)
    and page 1 is only ever the operator's. For anybody else the fallback was a page they did
    not own: a 404, then a second 404 the code had no handler for, and a blank screen at /.
    Found on production by opening an invite link in a browser that already held a session.

    There is now no client-side constant for the default page. A request that names none
    asks the server, and the answer names the page it got — which the device then remembers,
    so the next boot asks for the right one directly.

  • A browser spec that boots as a member (member.spec.ts), which no spec had ever
    done. That is the coverage gap the bug lived in: every other spec logs in as the
    operator, whose default genuinely is page 1, so all 244 of them agreed with it.
    Restoring the old fallback now fails that spec on the exact symptom — a blank screen.

  • A test that snapshotted an asynchronous outcome (render.spec.ts). The dev mark on
    the icons and the tab title are swapped when /health answers, which is after the
    editor is on screen; reading them once passed on every quiet machine and failed twice on
    2026-08-30 — once in CI, once in the production gate, where it skipped a deploy. Retried
    now; the assertions are unchanged.

  • The browser seed clears test members between runs. They accumulated in the local D1 and
    the people spec invites one per run, so MAX_USERS = 25 would have begun failing after
    about a dozen runs — as what looked like a broken cap.

Full changelog: v1.9.0...v1.9.1

1.9.0 — invite, revoke, change email, delete

Choose a tag to compare

@danjamk danjamk released this 30 Aug 19:02
1aaafc0

The operator can invite. The table says whether it is still free.

Invite, revoke, change email, delete — and the people pane (#232, ADR-008 §3, §4, §8,
§11, §12). The third of multi-user's five, and the one that makes an invitation possible.

Added

  • A hosting › people row in the sheet, for the operator only: a plain DM Mono table
    with one row per person — last seen, devices, pages, sittings, the agent's share, wipes
    and items done over thirty days — a totals row, and a N of 25 headline.
    No page content, ever.
  • Invite is an address and a button. The mail is the first login mail with invite
    framing and a seven-day link and no code — a code is bound to the browser that asked
    for it, and that browser was the operator's.
  • Revoke keeps their pages and ends every session and OAuth grant on the next request.
    Delete removes every row they own. Change email is the only recovery lever there
    is: tap the address, and a fresh invite goes to the new one — identity is the row, so
    the pages stay. Revoke and delete confirm by a second tap on the same control, never by
    a dialog.
  • MAX_USERS = 25 is a tripwire in the code, the way the nine-page cap is; a revoke frees
    a place.
  • The routes: GET /api/me; GET, POST /api/users; PATCH, DELETE /api/users/<id>
    (?hard to delete). Session or bearer. A member gets the 404 a missing route gets —
    same body, same status — so the surface is invisible to anyone it is not for. The
    operator can never be removed, by id or by self.
  • sessions.last_seen_at (migration 0012, additive), written only when more than an hour
    stale, so a 4-second poll never becomes a write per request.
  • A design brief for the pane, docs/design/admin-view-brief.md. It ships plain; the
    ruling comes after.

Changed

  • settings is no longer written (#234, release two of three). 1.7.0 mirrored the
    operator's agent instructions into the legacy table so a rollback would still read
    current text; this release removes that write and carries no migration touching it. The
    release after this drops the table — and this one has to be live first, or the drop
    lands under a Worker still writing to it (ADR-002 §3).

Full changelog: v1.8.0...v1.9.0

1.8.0 — email login

Choose a tag to compare

@danjamk danjamk released this 30 Aug 15:41
1243125

Type your email, get a mail. The passphrase retires.

Email login (#231, ADR-008 §2). The login screen keeps its shape — one field, an
optional device label, one button — and the field now takes an address.

Changed

  • The mail carries a link and a six-digit code: tap the link on a desktop and land
    on the page; type the code on a phone, into the home-screen app, whose cookie jar the
    link cannot reach. The code is bound to the browser that asked for it, so six digits
    and five attempts are safe; the link is 32 random bytes and works once.
  • A device that already holds a live session keeps it — no new row in Devices on a
    re-login.
  • The endpoint says nothing about who exists: known, unknown and throttled addresses
    get the same answer, and only the inbox tells them apart. One mail a minute, five an
    hour, per person.
  • Mail goes out through Resend behind one function; without the key, a local
    wrangler dev prints the code instead. Cloudflare's own Email Service was checked
    and is Workers Paid only, so it waits.
  • The passphrase is gone. KNAG_PASSPHRASE is read by nothing; the runbook in
    docs/deployment.md says when to delete it. Existing sessions carry over, so no
    device logs in again. Two new secrets — KNAG_OPERATOR_EMAIL, claimed by the first
    login request that names it, and RESEND_API_KEY — and one var, KNAG_MAIL_FROM.
  • The browser suite logs in through a seeded session row rather than the form; the
    form's two states have their own WebKit spec.

Full changelog: v1.7.0...v1.8.0