%unimsg 0 app/sijil/v1 -- Sijil — an infinite-canvas note-taking application, described completely -- enough to build a port or a clone from this document alone. -- -- What is here: the domain model, every card kind and its fields, the -- interaction surface, the persistence contract, the merge algebra, the -- offline behaviour, the access model, and the reasoning behind each -- decision that has one. A clone that keeps the shapes but discards the -- reasons will rediscover the same bugs. -- -- Two altitudes are mixed here deliberately, and marked where they change. -- Most sections state *rules a port must satisfy*. The algorithms section -- states *procedures it may transcribe*, with the constants, because prose -- cannot reproduce them: an earlier draft called the ink engine "pressure and -- velocity variable-width strokes" and treated that as a description. It is a -- label, and it hid a fortnight of work. -- -- What is genuinely not here: the stylesheet and DOM construction. Those are -- the only parts a port to another toolkit replaces outright. application @sijil { name "Sijil" tagline "Infinite-canvas note taking" version "0.3.x" live-at "https://takhzeen.ibrahim.ms/apps/sijil/" premise "A board, not a list. Everything is a card on an unbounded plane, positioned by the person rather than ordered by the machine." footprint { modules 23, source-lines 8891, dependencies 0, build-step :none } no-build-step "Native ES modules, no bundler, no transpiler. Not minimalism for its own sake: the app is served straight from a git repository by its host, and a build step the deployed app depends on has already broken it once — the deployment silently fell back to browser storage with no sign-in, and a query parameter in the URL was the only symptom." -- =================================================================== -- DOMAIN MODEL -- =================================================================== model { lawh "A board: the unit of storage, sharing and navigation. A lawh may contain a card that *is* another lawh, so boards nest arbitrarily deep. Every account has one home lawh, created server-side on first sign-in so two devices signing in at once land on the same record rather than each seeding their own. A sub-board's parent is verified on create — you may only nest under a board you can already edit, so a board cannot be smuggled into someone else's tree." identifiers "Prefix, underscore, eight base-36 characters, as in n_4f2k8x1a. Client-minted for cards, server-minted for boards. That asymmetry is why import runs in two passes." accents [ :violet, :cyan, :emerald, :amber, :rose, :blue, :slate ] document-doc "One JSON value per lawh. Readable on purpose — an op-log would be opaque, would need a build step, and would grow against the host's 1 MiB write ceiling." document [ | field type purpose | :id :text "server-minted board id" | :v :int "document schema version, currently 1" | :title :text "board name" | :titleM :int "merge stamp for the title" | :layout :symbol "free or grid" | :layoutM :int "merge stamp for the layout" | :by :text "device id of the last writer" | :updatedAt :int "epoch milliseconds" | :nodes :map "card id to card" | :edges :map "edge id to connector" | :tomb :map "deleted card id to stamp" | :members :seq "sharing list, owner-maintained" ] } -- =================================================================== -- CARDS -- =================================================================== cards { doc "Nine kinds. Every card carries the common fields; the kinds table lists what each adds." common [ | field type note | :type :symbol "one of the nine kinds" | :x :number "world coordinates, not screen" | :y :number "as above" | :w :number "width" | :h :number "height" | :color :symbol "an accent name" | :parent :text "containing frame, or null" | :z :int "stacking; containers sit at 0" | :m :int "merge stamp, assigned by diffing not at call sites" ] kinds [ | kind size adds purpose | :note "260x190" "title, html" "rich text" | :task "250x200" "title, items[{t,done}]" "checklist with progress" | :frame "420x320" "title" "container; children travel with it" | :media "280x210" "title, blobId" "image or video" | :ink "200x160" "title, strokes[]" "freehand drawing" | :lawh "132x124" "title, boardId, count" "a card that is another board" | :shape "190x115" "title, shape" "outline with a label" | :contact "290x250" "title, fields{}, blobId" "a person, often scanned" | :applet "unstated" "unstated" "animates but does not execute" ] lawh-card "Icon-sized rather than card-sized, because there is no content to give room to. Opening it navigates in. Deleting the card unlinks it: the board survives and stays reachable from the board list. Deleting a board is a separate, owner-only act." contact-fields [ :name, :title, :company, :email, :phone, :mobile, :website, :address ] contact-keeps-original "The photograph of the scanned card is kept, because a scan is a guess and the original is the only thing that can settle an argument with it." ink-storage "Flat array of x, y, pressure triples with size, colour and a hue offset. The engine renders variable width from pressure and velocity." marks "A card may carry an accent dot or an icon, chosen in the selection HUD." edges "Connectors between cards, made by dragging from one card's edge onto another, stored in their own map keyed by edge id." } -- =================================================================== -- INTERACTION -- =================================================================== interaction { canvas [ | gesture action | :drag-empty "pan the viewport" | :wheel "zoom" | :pinch "zoom" | :long-press "quick menu" | :marquee "select several cards" | :group-key "wrap the selection in a new container" ] card [ | gesture action | :drag "move" | :drag-corner "resize" | :drag-edge-onto "link two cards" | :drop-in-frame "the card belongs to the container and travels with it" | :click-lawh "navigate into that board" | :double-click "edit inline" ] full-view "A note opens full-screen using the same builders as the card, so the full view *is* the note rather than a second editor that has to be kept in step with it." ink-capture "Press the pen key and draw directly onto the board; ink sticks where it is put." layout-modes [ :free, :grid ] tidy "Rearranges the current lawh." navigation "Open a lawh card to go in; the breadcrumb walks parent ids back up; home returns to the account's home lawh; the last board is restored across devices." palette [ "Search and commands", "Toggle theme", "View mode", "Lawh settings", "Tidy this lawh", "About Sijil", "Save a copy of everything", "Open a saved copy", "Change where boards are kept", "Sign out", "Reload Sijil" ] } -- =================================================================== -- ALGORITHMS -- =================================================================== -- These are stated as formulae and constants rather than as prose, because -- prose is not enough to reproduce them. An earlier draft of this document -- said "pressure and velocity variable-width strokes" and called that a -- description; it is a label. The constants below are the behaviour, and a -- port that guesses them produces ink that looks wrong in a way nobody can -- name. -- -- Note the altitude difference: everything above is a rule the port must -- satisfy; this section is an algorithm it may transcribe. Both are -- legitimate, and mixing them without saying which is which is what made the -- earlier draft feel complete while omitting a fortnight of work. algorithms { ink { storage "Flat triples of x, y, pressure, so a busy board still serialises small. Rendering builds a closed outline rather than a stroked polyline, so width varies continuously and joins stay clean at any zoom." sample-admission "Reject a sample closer than 1.1 units to the previous one — pointer jitter would otherwise become visible texture." velocity "Exponentially smoothed: vel = vel * 0.72 + distance * 0.28." radius-formula [ | quantity formula note | :speed-factor "1 - clamp(vel / 26, 0, 0.62)" "fast is thin, slow is thick" | :has-pressure "pressure > 0 and pressure != 0.5" "0.5 is the value a device reports when it has no sensor" | :force "with pressure: 0.42 + pressure * 0.92" "pressure drives width directly" | :force-alt "without: 0.62 + speedFactor * 0.76" "velocity substitutes for pressure" | :radius "max(0.35, (size / 2) * force)" "never vanishes entirely" ] position-smoothing "px = prev.x + (x - prev.x) * 0.62, and likewise for y. Radius is smoothed separately and more gently: r = prev.r + (r - prev.r) * 0.34." outline "For each point, take the direction from its neighbours, normalise, and offset by the radius along the perpendicular to produce a left and a right rail. The path is the left rail forward, the right rail reversed, closed." taper "The last 14% and first 10% of a stroke narrow to a point, so a pen lift reads as a lift: r *= clamp(min((n-1-i)/(n*0.14), i/(n*0.10), 1), 0.12, 1). Highlighter strokes skip this and stay flat." curve "Quadratic through midpoints: for each interior point, a quadratic control at the point itself ending at the midpoint of it and its successor. Cheap, and visually smooth enough." single-point "A stroke of one sample renders as a circle of its radius, via two arc commands." } viewport { state "Three numbers: x, y translation and k scale. Applied as a single transform on the world layer — translate3d then scale — so the browser composites rather than relaying out." zoom-limits { minimum 0.08, maximum 6 } zoom-at-point "Zooming must keep the point under the cursor fixed: f = newK / oldK x = sx - (sx - x) * f y = sy - (sy - y) * f Anchoring on the viewport centre instead makes the board slide out from under the pointer." wheel "With a modifier held, zoom by exp(-deltaY * 0.01) at the cursor. Without, pan by subtracting the deltas. The handler is registered as non-passive because it must prevent the page's own scroll." fit "Zoom and centre on the bounding box of the given cards, or of all of them; an empty board resets to the viewport centre at scale 1." } layout { modes [ | mode behaviour | :free "a card sits exactly where it was put — the point of a canvas" | :autosnap "cards pack into columns, shortest first, no gaps" ] constants { grid-step 20, gutter 24, row-width 1160 } mode-belongs-to-the-lawh "It is part of what that board *is*, and everyone who opens it should find it as it was left." positions-do-not "The column count comes from the width of the window in use, so two devices pack the same board differently and neither is wrong. Autosnap coordinates are recomputed locally on every render and never synced. What *is* shared is the order, and that is what a drag changes." column-count [ | quantity formula why | :usable "max(col, width - pad * 2)" "the space actually available" | :ideal "floor((usable + gutter) / (col + gutter))" "how many preferred-width columns fit" | :columns "max(1, min(ideal, cardCount))" "never more columns than cards" | :colWidth "(usable - gutter * (columns - 1)) / columns" "columns share the whole width" ] why-both-halves "Fixing cards at their preferred width left the board a narrow block with wide empty margins — space, on a board whose whole purpose is not to have any. Capping the count at the number of cards is what stops three cards on a wide screen using three of eight possible columns and calling the other five nothing." contained-cards-are-left-alone "Cards inside a container belong to the frame and travel with it; lifting them into the top-level flow would quietly dismantle a grouping someone made on purpose." } stacking "Containers sort before ordinary cards, then by the z field, so a frame never paints over its own children." } -- =================================================================== -- PERSISTENCE -- =================================================================== persistence { seam "One module is the entire backend integration. Feature code never calls the network or browser storage directly — if it did, adding the second backend would touch every file." interface [ :load, :save, :putBlob, :blobURL, :me, :share ] also-provides [ :list, :home, :create, :reparent, :deleteBoard, :warm, :isSaved ] adapters [ | name storage identity nesting sharing | :local "browser" :none false false | :takhzeen "server account" "email, OAuth" true true ] adapter-chosen :at-runtime adapter-selection "The page's own URL decides: a path of /apps/{slug}/ or an apps subdomain means a backend is present, anything else is local; an override variable forces either. This replaced a build step that rewrote an import statement. When the host began serving the repository as-is that step stopped running, and the deployed app silently used browser storage." save-debounced "Saves are debounced and must be flushed before navigating away, or the write is discarded. Omitting the flush made every lawh open mint a new board." undo "Snapshot-based. Restored cards are restamped — without that, a tombstone another replica still carries outranks the restored card and deletes it again on the next sync." } -- =================================================================== -- MERGE -- =================================================================== merge { doc "A deliberately small CRDT: a last-write-wins map keyed by card id, plus tombstones. Merge is commutative, associative and idempotent, so devices syncing in any order converge. Those three laws are asserted directly by tests — if one breaks, two devices hold different boards." laws [ :commutative, :associative, :idempotent ] clock "Hybrid logical. A stamp is the local wall time, but never less than the highest stamp already seen, plus one. Wall clocks disagree between devices; this keeps stamps monotonic per device and roughly ordered across devices. Ties break on device id — arbitrary, but identical everywhere, so all replicas agree." clock-fits-a-double "A stamp is milliseconds since the epoch, around 1.8 x 10^12, which is far inside the 2^53 a double-backed integer holds exactly. This matters on any runtime where integers are doubles — JavaScript, and Dart compiled to it — and it is currently true by accident rather than by statement. A port that raises the resolution to microseconds is still inside the range; nanoseconds are not, and neither is packing a device id into the low bits to make ties resolve arithmetically. Both are natural things to do when reimplementing this, and both fail silently, on web only, while every native test passes. If a stamp stops being a millisecond count, say what it is and check it against the target." stamping "Stamps are found by diffing each card against its last saved shape, not by stamping at call sites. Edits are written directly all over the application — a coordinate during a drag, markup from an editor — so any scheme relying on remembering to stamp would be wrong within a week." load-bearing [ | rule if-dropped | "deletions write a tombstone" "merge cannot tell deleted from not-yet-created, so everything deleted offline returns" | "undo restamps" "a surviving tombstone outranks the restored card and deletes it again" | "the viewport is never merged" "where someone is looking would be forced onto their other devices" ] conflicts "Granularity is the whole card: two people editing the same card resolve to one winner. Nothing is discarded silently — the loser is kept as a copy beside the winner, offset and marked." rejected [ :yjs, :automerge ] why-rejected "They replace a readable document with an op-log, add a build step to a project that has none, and grow history against the 1 MiB ceiling. What they buy is character-level merging of concurrent text, which needs live multiplayer — and this host's realtime applies the collection's list rule, so a closed collection emits nothing. Paying that cost for a capability the backend cannot deliver is the wrong trade. Revisit if live co-editing ever lands." } -- =================================================================== -- OFFLINE -- =================================================================== offline { doc "A wrapper around the remote adapter, so neither the shell nor the document model knows anything about the network. Reads are cached, writes land locally first, and a board edited offline is reconciled on reconnect through the merge." rules [ | rule reason | "connectivity judged by whether calls work" "the browser's online flag reports the interface and says online behind a captive portal; its event only prods a retry" | "only network failures count as offline" "a refusal such as cannot-read-this-board is a perfectly connected answer" | "reconcile writes the cache too" "skipping it left a reload straight after a sync showing the board without what the merge had just pulled in" | "save errors are swallowed while offline" "an error per keystroke is noise, and the edit is safe locally" ] polling "Other devices' edits arrive by polling every 20 seconds while the tab is visible and idle — not a subscription, because the host's realtime applies the list rule and these collections are closed, so it would emit nothing. Safe to poll because the merge is idempotent: finding nothing costs one read." polling-takes-the-board-explicitly "The adapter's internal notion of the last document touched moves on an import or a background save, and polling that would refresh a board nobody is looking at while the visible one went stale." polling-writes-back-conditionally "Only when this device holds something the server does not, or every open tab takes turns saving the same board to each other." polling-skipped "Mid-gesture and mid-sentence, so a merge cannot pull a card out from under the pointer." app-shell "A service worker caches the shell so the app starts with no network. Without it the data was local but the code was not, so use-on-this-device-only still needed a server. Network first, cache as fallback: deploys happen by pushing to the repository, and a cache-first worker would serve yesterday's modules to someone perfectly online." service-worker-trap "A plain fetch inside a worker still passes through the browser's HTTP cache, so network-first silently became HTTP-cache-first and served stale code. An explicit no-cache on the worker's fetch is load-bearing." api-never-cached "A stale board read is worse than an honest failure." } -- =================================================================== -- ACCESS -- =================================================================== access { roles [ | role may | :owner "everything, including sharing and deleting the board" | :edit "change what the board holds, but not share or delete it" | :comment "declared; currently behaves as view" | :view "read" ] why-functions "The host's collection rules are per-collection, not per-record: owner means only the creator so sharing is impossible, auth means every signed-in user reads every board, public means the internet does. None expresses shared-with-these-three-people. So every collection is closed and access runs through functions, which bypass rules and can read a verified caller identity that cannot be forged from function input." collections [ | collection rules fields | :boards :closed "title, doc, owner_id, owner_name, updated_by, parent_id, is_home" | :board_members :closed "board, user_id, email, name, role" | :board_assets :closed "board, mime, name, blob" ] functions [ | function restricted-to purpose | :board_home :caller "resolve or create the home lawh" | :board_list :caller "boards owned by or shared with the caller" | :board_get :member "read one board" | :board_save :editor "write one board" | :board_share :owner "membership changes" | :board_delete :owner "delete one board" | :asset_put :editor "store media" | :asset_get :member "fetch media" ] constraints [ | rule reason | "sharing is owner-only" "otherwise an edit member could widen access" | "deletion is owner-only" "an edit member may change what a board holds but not destroy it" | "deletion refuses a home lawh" "it would strand the account it belongs to" | "deletion is shallow" "recursion is left to the client, so a deep tree cannot exhaust the function deadline halfway with no way to report where it stopped" | "membership is keyed by email" "so a person can be invited before they have ever signed in; the invitation waits, including for a provider that links on verified email" ] filter-precedence-trap "The membership clause must be written board = X && (user_id = U || email = E). The parentheses are load-bearing: without them it binds as (board = X && user_id = U) || email = E, which matches the caller's membership of *any* board and hands them boards they were never invited to." verified-by "24 end-to-end checks covering ownership, sharing, role enforcement, revocation, direct-access bypass attempts and media gating." } -- =================================================================== -- TRANSFER -- =================================================================== transfer { doc "One job — read every board and its media out, write them back in somewhere else — which covers three: a backup, moving work from a device into an account, and moving between servers. There is no separate migration tool because there does not need to be." two-pass "Board ids are minted by the server, so every one changes on the way in. Create every board first to learn its new id, then write the documents with references remapped." remap [ | reference held-by if-missed | :boardId "lawh card" "cards that open nothing" | :blobId "media card" "pictures that never load" ] silent-failure "Both failures are silent, because both remain valid ids that simply do not exist on this server." quiet-by-design "Boards made before signing in follow the person in automatically, once, with a toast as the only sign. Dropping a saved copy on the board just opens it — the file is sniffed, so there is no import screen to find. The palette offers saving and opening a copy for anyone who goes looking, with no jargon and no format talk." sharing-travels "A membership is an email, a name and a role with no server-specific id, so it can be restored elsewhere. Carried only for boards you own, and restored only when asked: re-inviting people grants access on a server they may know nothing about, so it is never a silent effect of opening a file." } -- =================================================================== -- INTERFACE RULES -- =================================================================== interface { two-menus "The mark in the top bar opens *app* things — search, theme, about. The account chip opens *your* things — where boards are kept, saving a copy, changing mode, signing out. Keeping that split is why both menus stay short, and it answers the two questions people actually arrive with." rules [ | rule reason | "signing out is a menu item, never the chip" "tapping your own picture to see who you are should not end your session" | "local mode shows a device chip" "otherwise saving a copy is only reachable from a palette needing a keyboard, which on a phone is the same as not existing" | "menus listen on the capture phase" "so a click on the trigger closes the open menu instead of reopening it underneath" | "dialogs dismiss with a mark top right" "never a button along the bottom; escape and a backdrop click also work" | "switches are toggles, not tickboxes" "for settings; a task card's checklist is a to-do rather than a setting, so it keeps tickboxes" | "theme is applied in the head, inline" "applying it after the stylesheet paints gives a light-mode user a dark flash and then watches every transitioned element animate, which reads as broken chrome rather than a fade" | "the hidden attribute needs an important" "it loses to any author display rule, and without the override overlays render over the board and the share sheet eats every click" | "entrance animates scale, not transform" "transform carries each card's world position, so a keyframe on it yanks every card to the origin" ] } -- =================================================================== -- CONSTRAINTS -- =================================================================== constraints { write-ceiling { bytes 1048576, applies-to "one board document, and one media upload" } media "Downscaled client-side through a quality ladder of 0.85, 0.7, 0.55, 0.4. Video works but is capped by the same ceiling, so clips must be a few seconds; anything larger is refused with a clear message rather than failing silently." same-origin-only "An app served by this host runs under a connect-src of self, so it can only ever talk to the origin it came from. Pick-a-server therefore means this server or none — an arbitrary address would be blocked by the browser before the request left. This would widen if the app were packaged natively, where the policy does not apply." provider-avatar "It reaches the client only in the sign-in callback fragment. The host stores the name but not the picture, so a token refresh never returns it; it must be captured then or it is gone for good." } -- =================================================================== -- STATE -- =================================================================== state { working [ "sign-in by password and by provider, with the provider's avatar", "home lawh resume across devices", "nested lawhs with back and home navigation", "sharing with view, comment and edit roles, enforced server-side", "card marks", "media upload with client-side downscaling", "ink", "containers", "connectors", "command palette", "undo", "offline use with reconciliation", "backup and transfer between servers" ] not-done [ | feature status | :live-multiplayer "blocked — the host's realtime applies the list rule, so closed collections emit nothing; a function-authorised subscription would unblock it" | :comments "the role exists but behaves as view" | :workflows "animate but do not execute" | :applets "designed, not built" ] } -- =================================================================== -- CONFORMANCE -- =================================================================== -- The point of this section is to make "derived from this document" a claim -- a machine settles rather than a claim a person asserts. Everything below -- is a pure function: two documents in and one out, a caller and an action -- and an outcome, samples in and radii out. No browser, no server, no board. -- -- An implementation conforms when it satisfies every vector here. It may be -- written in any language, by any means, sharing no code with the original — -- exactly as three independent implementations of unimsg itself were -- verified against a table of vectors carried in its specification. -- -- The suite is deliberately small and load-bearing rather than broad. Each -- vector below corresponds to a failure that has actually happened or that -- would be silent if it did. conformance { claim "An implementation is derived from this document if it satisfies every vector in this section. Nothing else is required, and passing by a different internal design is not merely permitted but expected." claims-name-their-platform "A conformance claim states where the vectors were run. An implementation may claim as many platforms as it likes and may not claim none. This is not a testing mandate — the specification has no business dictating how anyone works — but `all vectors pass` and `all vectors pass on Flutter web` are different statements, and only the second invites the question that matters. A sibling project's implementation passed every vector natively and could not encode a floating-point number in a browser, which was its own stated target." drift "This is also the only defence against the document going stale. Run the suite in the build: when the implementation stops satisfying it, the divergence surfaces immediately rather than silently, and the document stops being confidently wrong." -- --------------------------------------------------------------- -- MERGE — the three laws plus the cases each protects -- --------------------------------------------------------------- merge { notation "A card is written id/stamp, optionally with a field. A tombstone is written id@stamp. Merging is written a+b." laws [ | law statement vector | :commutative "a+b equals b+a" :m-001 | :associative "(a+b)+c equals a+(b+c)" :m-002 | :idempotent "(a+b)+b equals a+b" :m-003 ] vectors [ | id a b expect guards | :m-001 "n1/10, n2/20" "n2/30 x=99, n3/15" "n1, n2 x=99, n3 — same either order" "order of sync changing the board" | :m-002 "n1/10" "n1/30 x=3, n2/11" "with c=n1/20 x=2, n3/40: n1 x=3 both pairings" "three devices diverging on pairing" | :m-003 "n1/10" "n1/20 x=5, n2/12" "merging b twice more changes nothing" "repeated polls mutating the board" | :m-004 "tomb n1@50" "n1/10" "n1 absent, tomb n1@50 carried" "a card deleted offline coming back" | :m-005 "tomb n1@50" "n1/80 x=7" "n1 present, x=7" "a delete outranking a later edit" | :m-006 "tomb n1@50, n2@10" "tomb n1@20, n3@70" "tomb n1@50, n2@10, n3@70" "tombstones regressing to older stamps" | :m-007 "n1/10 by=dev-a x=1" "n1/10 by=dev-b x=2" "identical result either order" "a tie resolving differently per device" | :m-008 "n1/10 html=mine" "n1/20 by=dev-b html=theirs" "keeps theirs, reports lost=mine" "a losing edit vanishing silently" ] tie-break "Equal stamps resolve on device id. Arbitrary, but identical everywhere, so every replica agrees — which is the only property a tie-break needs." conflict-requires-a-base "A conflict is a card *both* sides changed since they last agreed, which cannot be judged from the two versions alone. The base carries the stamp each card held when this replica and the server were last in step. One side moved off it: take that one, nothing is in dispute. Both moved: a genuine clash." self-conflict-vector [ | id local server expect guards | :m-009 "n1/20 html=typed-now" "n1/10 html=last-saved" "no conflict; local simply ahead" "a device forking against its own unsaved edit" ] why-m-009 "Without it, a poll landing in the gap between a keystroke and the debounced save reported a clash against this device's own work, and the board filled with (conflict) copies of one note while nothing else was editing it." } -- --------------------------------------------------------------- -- ACCESS — the matrix, as outcomes rather than as prose -- --------------------------------------------------------------- access { note "Every row is a request that must succeed or must fail. The failures matter more than the successes: each is a way in that was tried." matrix [ | id caller action expect | :a-001 :owner "create a board" :allowed | :a-002 :owner "read own board" :allowed | :a-003 :stranger "read the board" :refused | :a-004 :anonymous "read the board" :refused | :a-005 :stranger "read the record directly" :refused | :a-006 :stranger "list the collection directly" :refused | :a-007 :owner "share the board" :allowed | :a-008 :invited-editor "read" :allowed | :a-009 :invited-viewer "read" :allowed | :a-010 :invited-editor "write" :allowed | :a-011 :invited-viewer "write" :refused | :a-012 :invited-editor "reshare" :refused | :a-013 :non-owner "reshare" :refused | :a-014 :removed-member "read" :refused | :a-015 :owner "list boards" "sees own" | :a-016 :member "list boards" "sees the shared board" | :a-017 :removed-member "list boards" "sees nothing" | :a-018 :owner "obtain an upload ticket" :allowed | :a-019 :owner "upload through the ticket" :allowed | :a-020 :owner "obtain a read ticket" :allowed | :a-021 :owner "read the asset back" "byte for byte" | :a-022 :member "read the asset" :allowed | :a-023 :stranger "read the asset" :refused | :a-024 :stranger "obtain any ticket" :refused | :a-025 :stranger "read the asset directly" :refused | :a-026 :anyone "use a tampered ticket" :refused | :a-027 :owner "delete a home lawh" :refused | :a-028 :invited-editor "delete the board" :refused ] the-filter-vector "The membership query must be board = X && (user_id = U || email = E). Test it by giving a caller membership of an unrelated board and asking for this one: the answer must be refused. Without the parentheses it is allowed, and that is a data leak rather than a bug." } -- --------------------------------------------------------------- -- INK — the constants, as a sequence a port can check against -- --------------------------------------------------------------- ink { note "Size 6, samples along a straight line so only pressure and spacing vary. Radii are what the documented formulae produce; a port whose numbers differ has a different pen, whatever its code looks like." given { size 6, samples "x spaced 0, 10, 24, 40, 58 on one axis" } vectors [ | i pressure x y radius | 0 0.30 0.0 0.0 2.088 | 1 0.60 6.2 0.0 2.370 | 2 0.90 17.2 0.0 2.837 | 3 0.55 31.3 0.0 2.817 | 4 0.20 47.9 0.0 2.475 ] also-check [ "a sample closer than 1.1 to its predecessor is dropped entirely", "a stroke of one sample renders as a circle of its radius", "the last 14% and first 10% taper, floored at 0.12 of full radius", "a highlighter stroke skips the taper and stays flat" ] } -- --------------------------------------------------------------- -- LAYOUT — packing is a pure function of cards and width -- --------------------------------------------------------------- layout { given { width 1200, column 268, gutter 24, pad 12 } vectors [ | card w h x y columns | :a 260 190 12.0 12.0 4 | :b 260 120 312.0 12.0 4 | :c 260 300 612.0 12.0 4 | :d 260 80 912.0 12.0 4 ] also-check [ "four cards on a 1200 width produce four columns, not one per card", "a fifth card lands under the shortest column, leftmost breaking a tie", "only a stretchable kind is resized; the others keep their own width", "cards inside a container are not moved at all", "positions are recomputed per device and never synced; order is synced" ] } -- --------------------------------------------------------------- -- TRANSFER -- --------------------------------------------------------------- transfer { vectors [ | id given expect | :t-001 "export two boards, one holding a lawh card" "import remaps boardId to the new server-minted id" | :t-002 "export a board holding a media card" "import remaps blobId and the picture loads" | :t-003 "import a file with memberships, without asking" "nobody is invited" | :t-004 "import the same file with sharing requested" "members are restored by email" ] note "t-001 and t-002 are the two silent failures: both ids stay valid and simply point at nothing on the new server, so a port that skips the remap looks like it worked." } -- --------------------------------------------------------------- -- RUNNING IT -- --------------------------------------------------------------- running { merge-and-layout-and-ink "Pure functions. No browser, no server, no board — feed the inputs, compare the outputs." access "Needs a live instance and three accounts. The existing script registers them, runs the matrix and leaves them behind." in-the-build true why-in-the-build "A suite that is run when someone remembers is a suite that reports drift after the drift has been shipped." } } -- =================================================================== -- PORTING -- =================================================================== porting { must-reproduce [ "the document shape and every card kind's fields, or saved boards will not load", "the merge algebra — tombstones, the hybrid clock, restamping on undo — or devices diverge", "the two-pass import with both references remapped, or transfers fail silently", "owner-only sharing and deletion, and the parenthesised membership filter, or access control is wrong rather than merely different", "the adapter seam, or the second backend touches every file" ] absent-is-not-a-value "Every field that may be missing needs a stated meaning, and this document does not yet give one consistently — an audit not performed rather than an audit passed. Where it is silent, an implementer must choose, and two implementers choosing differently both conform. Treat any unstated default as a question for the author, not a decision to make quietly." free-to-replace [ "all presentation — stylesheet, DOM construction, animation", "the ink engine's curve fitting, provided stroke storage is unchanged", "the host, provided the eight functions and three collections have equivalents" ] what-actually-transfers "Measured, from an independent Flutter port of a sibling project written from its specification. Worth knowing before deciding how to write anything you need reproduced faithfully." transfer-rates [ | form-in-the-document outcome | "a table of unambiguous values" "reproduced exactly, every value" | "a table whose cells are English" "reproduced in part" | "a value competing with a toolkit default" "the toolkit won" | "a prose description of behaviour" "reinvented or absent" ] so "Anything that must come out the same goes in a table as values. A sentence describing behaviour is satisfied by whatever the reader understood it to mean, which is how two implementations pass the same suite and cannot read each other. Where a value competes with a framework default, say why it matters or expect to lose it." dart-and-flutter { note "Specific to the port being undertaken; harmless elsewhere." hazards [ | hazard what-happens | "ByteData.getUint64" "unsupported under dart2js; throws at run time, works natively" | "64-bit bitwise arithmetic" "silently yields zero on the web — (hi << 32) | lo gives 0" | "BigInt.from(a literal above 2^53)" "refuses to compile for the web" | "int is 53-bit on the web and 64-bit natively" "the same code is exact on one target and not the other" ] where-they-would-bite "Anywhere this document says :int and something larger than a millisecond count ends up there, and anywhere binary data is taken apart by hand rather than passed through. The merge stamp is safe as specified — see clock-fits-a-double — and would stop being safe under an obvious optimisation." test-on-the-target "Compiling for the web is not running on it. A smoke test that only confirms the code builds will pass while every floating-point path throws, which is a thing that has actually happened." } order [ | step build because | 1 "document model and store" "everything else reads and writes it" | 2 "board, viewport, cards" "the product is visible from here" | 3 "local adapter" "usable without any backend" | 4 "merge, with its three laws tested" "before a second device exists" | 5 "remote adapter and functions" "sharing and nesting need identity" | 6 "offline wrapper" "it wraps the remote adapter, so it comes after" | 7 "transfer" "needs both adapters to be worth having" ] } -- =================================================================== -- THE TAKHZEEN WIRE CONTRACT -- =================================================================== -- Added to answer a deficiency report that found this document specified the -- application and not the connection: a port could build Sijil and could not -- talk to a server without inventing shapes. Every contract below was read -- out of the deployed function sources and the takhzeen server, which are -- authoritative over any prose — including over this section. -- -- Two of the fifteen deficiencies are not answered here because the answer -- is that the thing does not exist. They are recorded as such rather than -- filled in, which is the more useful outcome. integration { boards-are-closed "Every rule on boards, board_members and board_assets is `none`. There is no direct record access at all: the eight functions are the entire surface, and a client that tries the collection routes gets nothing." every-function-is-invoke-rule-auth "So an unauthenticated call fails before the body runs. The functions still check `takhzeen.auth()` themselves, because the rule protects the endpoint and the check protects the record." -- --------------------------------------------------------------- -- THE EIGHT FUNCTIONS -- --------------------------------------------------------------- functions [ | name sends returns | :board_home "nothing" "id, title, created" | :board_list "nothing" "boards, an array of summaries" | :board_get "id" "id, title, doc, role, parentId, isHome, ownerName, members" | :board_save "id?, title, doc, parentId?" "id, role, created, parentId" | :board_share "id, members" "the updated membership" | :board_delete "id" "id, deleted, members, assets — or alreadyGone" | :asset_put "board, mime, name?" "id, mime, upload" | :asset_get "id" "mime, name, url, expires" ] board-summary "Each entry of board_list carries the board's id, its title, the caller's role, and its parent — enough to draw the tree without a second call." create-or-update "board_save does both, discriminated by `id`. Absent, it creates and returns `created: true` with the caller as owner. Present, it updates and requires the caller to be owner or an `edit` member; `view` and `comment` are refused." reparent-is-the-same-call "Sending `parentId` on an update moves the board. The key's *presence* is what triggers a move, so an update that omits it leaves the parent alone and one that sends an empty value moves the board to the top. A client that always sends every field will reparent on every save." reparent-refusals [ | condition refusal | "the board is the home board" "a home lawh cannot be moved" | "the new parent is the board itself" "a lawh cannot be moved into itself" | "the new parent is inside the board's own subtree" "the move would detach that subtree" | "the caller lacks edit on the new parent" "you cannot add a board here" ] the-cycle-check "The server walks up from the proposed parent looking for this board. A client need not repeat the walk, and must handle the refusal — dragging a board onto its own descendant is an ordinary gesture, not a malformed one." delete "Owner only, and the home board cannot be deleted. Deleting reports how many memberships and assets went with it. A second delete of the same board succeeds with `alreadyGone: true` rather than failing, so a retry after a lost response is safe." -- --------------------------------------------------------------- -- WHAT A DOCUMENT TRAVELS AS -- --------------------------------------------------------------- document-envelope { answer "A JSON object, in a `json` column named `doc`." not-unimsg "The deficiency report asked whether a board travels as canonical unimsg text or as CBOR. It is neither. The board document is JSON, and the format of this specification is not the format of the application it specifies." round-trips-as-text "A takhzeen `json` field is stored as TEXT, so it comes back from a record read as a string and the functions parse it. A client sends an object and receives an object; the string is internal to the server and to the function." size "board_save refuses a document over roughly 900 000 bytes, held below the 1 MiB an invocation may carry. The refusal is by encoded size, so a document that grew by one image is refused whole." } -- --------------------------------------------------------------- -- CONCURRENCY: THE GAP -- --------------------------------------------------------------- concurrency { status :unresolved finding "There is no server-side revision, version, etag or compare-and-swap. Not in the collections, not in the functions, not anywhere. board_save writes what it is given." consequence "Two clients that both hold a board and both save will have the second write win entirely. The offline merge this document specifies runs in the *client*, against the copy it last saw — and nothing on the server prevents a client that never saw the other's work from overwriting it." why-it-has-not-bitten "Sijil's merge reconciles before saving, and a single user with two devices usually serialises naturally. The exposure is two clients writing the same board within one save cycle." what-would-close-it "A revision column on boards, returned by board_get and required by board_save, refused when stale. That is a change to the collections and to two functions, not to this document — recorded here so a port does not assume the protection exists." what-a-port-must-do-meanwhile "Implement the client-side merge exactly as specified, and do not rely on the server to catch a lost update. It will not." } -- --------------------------------------------------------------- -- MEDIA -- --------------------------------------------------------------- assets { why-tickets "A function invocation may carry 1 MiB and would pay a base64 third. Instead the function authorises the transfer and returns a short-lived URL the client uses directly, so a file is bounded by the field's own ceiling." upload [ | step call gives | 1 "asset_put with board, mime, name" "id, mime, upload" | 2 "PUT or POST the bytes to `upload`" "the file is stored" ] download [ | step call gives | 1 "asset_get with id" "mime, name, url, expires" | 2 "GET `url`" "the bytes" ] ticket-shape "The URL is /api/db/{slug}/files/t/{token}, where the token is an opaque signed string naming one database, collection, record, field and mode, with an expiry. It grants that one file in that one direction and nothing else." lifetimes [ | mode seconds | :write 600 | :read 900 ] the-blob-id "The `id` returned by asset_put is the board_assets record id, and it is what a board document should store. asset_get takes that same id. There is no separate blob identifier — a document holding anything else will resolve to nothing, which is the failure the deficiency report anticipated." access "Both functions check the caller's role on the owning board before minting a ticket. A ticket cannot be obtained for a board the caller cannot reach, and an already-minted ticket is not revoked by losing access — it simply expires." } -- --------------------------------------------------------------- -- IDENTITY -- --------------------------------------------------------------- identity { login "POST /auth/login with email and password returns `token`, `expires`, and `record` — the record holding id, email, name, verified, created and updated." the-same-shape "refresh returns the same three keys. `me` returns the record alone. logout returns nothing of interest." a-401 "means the session is over. Clear the token; do not retry." providers "GET /auth/oauth lists what this database has enabled. Each entry carries the provider's name and its start URL. Do not hard-code a provider: the list is per database." the-fragment [ | parameter present-when carries | "token" "the round trip succeeded" "the session token" | "name" "the provider supplied it" "a display name" | "picture" "the provider supplied it" "an avatar URL" | "error" "it failed or was cancelled" "a message to show" ] read-the-fragment-once "name and picture arrive here and nowhere else — a later refresh will not return the avatar. A client that wants it must capture it on the callback and keep it, then wipe the fragment so a copied link carries no session." only-http-image-urls "The avatar goes into an img element. A picture value that is not http or https must be discarded rather than rendered." } -- --------------------------------------------------------------- -- DEPLOYMENT -- --------------------------------------------------------------- deployment { one-push "The `takhzeen/` directory in the repository is read on push and applied before the ref moves. The same commit deploys the site." layout "takhzeen/takhzeen.json, takhzeen/collections/.json one per collection, and takhzeen/functions// holding function.json and the source file." collection-file [ | key holds | "name" "the collection's name" | "listRule, viewRule, createRule, updateRule, deleteRule" "one of none, owner, auth, public" | "fields" "an array of field objects" ] field-object [ | key applies-to | "name" "always" | "type" "always — text, number, bool, json, file, relation, select, date, email, bytes" | "required" "always" | "options, maxSelect" "select" | "relatedCollection" "relation" | "maxSize" "file" ] function-file [ | key holds | "name" "must match the directory" | "description" "shown in the dashboard" | "language" "python" | "timeoutSeconds" "15 for every Sijil function" | "invokeRule" "auth for every Sijil function" ] apply-is-additive "Removing a file reports the difference and changes nothing. Deliberate removal goes through the CLI with -prune. A port that renames a collection has created a second one." } -- --------------------------------------------------------------- -- ERRORS, PAGING AND LIMITS -- --------------------------------------------------------------- errors { status :partially-unresolved shape "A function that refuses returns `{\"error\": \"...\"}` with HTTP 200. The message is prose written for a person." the-gap "There are no stable machine-readable codes. A client cannot reliably distinguish signed-out from refused from missing from transient, except by matching English text — which the deficiency report identified and which reading the source confirms rather than resolves." what-can-be-relied-on "Only two things. An HTTP 401 from any route means the session ended. An `error` key present in a function result means the call did not do what was asked. Everything finer requires matching prose, and a port should isolate that matching in one place so it can be replaced when codes exist." the-deliberate-ambiguity "board_get answers `not found` both for a board that does not exist and for one the caller may not see, so a probe cannot enumerate board ids. That is intentional and must not be 'fixed' by a port into two distinguishable errors." } paging { record-routes "perPage defaults to 30 and is clamped to 200. A value above 200 is silently reduced, so a client that asks for 1000 and reads what returns has truncated its own view." the-functions-do-not-page "board_list gathers up to 200 owned and 200 shared boards and returns them in one array. An account past that ceiling is silently truncated, and no cursor is offered." status :bounded-not-paged } limits [ | limit value | "function invocation body" "1 MiB" | "board document" "about 900 000 bytes, refused above" | "board_share members" "bounded per call" | "record page" "30 by default, 200 maximum" | "write ticket" "600 seconds" | "read ticket" "900 seconds" | "repository" "256 MiB, never collected" ] -- --------------------------------------------------------------- what-remains-open [ | id item why | :D05 "no server-side conditional write" "the mechanism does not exist; a change to the system, not to this document" | :D13 "no stable error codes" "functions return prose; matching it is the only option today" | :D14 "board_list is bounded, not paged" "no cursor exists; an account past 200 boards is truncated" | :L01 "the deployed takhzeen version" "source may be ahead of or behind the running instance" | :L02 "which OAuth providers are enabled" "per database, and only the live instance knows" | :L03 "public origin and redirect allowlist" "operator configuration" | :L06 "the end-to-end access matrix" "owner, editor, viewer, removed member, stranger and anonymous must be checked live" ] everything-else-is-answered "The remaining eight deficiencies — the function shapes, create-or-update, reparent, the document envelope, the ticket exchange, the blob mapping, the sharing shapes, the identity bodies, the OAuth fragment, and both deployment schemas — are specified above, read from the sources the report named." } }