Changelog
Everything built in SecureTrace so far, newest first, grouped by what each stretch of work was about. Each entry says what it means in plain terms, then how it actually works.
262 changes across 44 stages of work, 25 ISO 27001 Annex A controls moved.
Two people can no longer overwrite each other's revision
LatestA file can be checked out while somebody revises it, so nobody else's upload can quietly supersede their work.
- BuiltCheck a file out while you revise it, and check it in when you upload the new version.
Two engineers both download version 3, both revise it, and the second upload silently replaces the first. Now either of them can check the file out from its Versions tab, with an optional note on what they are changing, and until they check it in nobody else can add a version — the upload is refused with their name, and the file list shows a lock beside it. Reading, downloading and sending are untouched, and a file nobody has checked out works exactly as before. The holder's upload dialog becomes Upload and check in, which stores the version first and lets go only after the bytes have landed, so a failed upload leaves them still holding it; they can also release it without a new version. An administrator can break a check-out somebody left behind, with a written reason recorded as a warning beside the holder's name. The server holds the rule: one holder per file by the table's own key, the file locked while a check-out or a version is written so the two cannot pass each other, and the holder always taken from the signed-in session. Every check-out, check-in and break is on the file's Activity tab.
What a colleague, or an administrator, sees on a file somebody else is revising.
Download monitoring stops mistaking an ordinary recipient for a burst
A recipient taking one copy of each file in their release is no longer reported as an anomaly, automatic withdrawal acts only on the rules that cannot mistake an ordinary recipient for one, and the rules that say so can be read in full on the Policies page.
- TightenedAutomatic withdrawal of a release now acts only on download bursts and portal-view bursts, never on a recipient seen at two addresses.
An administrator can let the hourly sweep withdraw a live release when it records an anomaly, and that switch started off because one of the rules could not tell an ordinary recipient from a problem: downloads from two addresses within an hour is also a laptop moving from the office network to a phone's hotspot. Telling those apart needs to know where the addresses are, which needs a geolocation database the product does not ship, so the rule cannot be narrowed into safety. Instead the switch now acts only on download velocity and the portal view rate, both calibrated against what a recipient normally does; a dispersion finding is still detected, recorded and shown, and left to a person even with the switch on — the sweep's log says so. The rule list is written out per rule, so a new detector cannot inherit withdrawal without somebody deciding, and the build fails if dispersion is ever allowed to withdraw. The switch still starts off: that is now the owner's choice rather than a workaround.
The two download rules now have a row each, because the sweep treats them differently. - FixedA recipient who downloads one copy of each file in their release is no longer flagged as a download burst.
The velocity rule fired on more than three downloads in ten minutes by one person, so a supplier downloading a four-file release once each, in one sitting, was reported as an anomaly — the reason the sweep's switch to withdraw a release on an anomaly had to start off. A recipient now earns one extra download for each further file of their release taken in the window. One copy of each file is never a burst however many files there are; four copies of one file still fires, as it always did; two copies of every file fires. The allowance is counted from the files actually downloaded in the window rather than the release's size, so the detector still reads nothing but the trail and needs no clock. Downloads inside the company earn no allowance: someone pulling thirty different files out of the tree in ten minutes is the burst that matters most there. The dashboard's monitoring card and the Policies page state the allowance from the same constant. The switch still starts off, because the second rule — downloads from more than one address in an hour — also describes a laptop moving between networks.
The allowance is published from the detector's own constant, so the sentence and the rule cannot disagree. - FixedThe Policies page's data-leakage rules wrap their detail instead of running off the side of the card.
The Detail column's header asked for a minimum width so the column would wrap, but the table cells keep their text on one line by default, so nothing ever wrapped: the table was about 3,300 pixels wide inside a 1,136-pixel card, and the one column a reader came to the card for could only be read by scrolling sideways. The detail cells now wrap, the same fix the Settings page's tables already carry.
Each rule's sentence now fits the card; it used to sit on one line nearly three cards wide, behind a horizontal scroll.
The trail keeps what the server refused, not only what it allowed
A release the server stopped now leaves a record naming the rule that stopped it, so the evidence shows controls holding as well as transfers going out.
- BuiltA release the server refuses is recorded in the audit trail, naming the rule that refused it.
Six rules can stop a release at the server: the recipient has not been verified, the vendor has no current agreement, a domain-locked release names an address outside the vendor's registered domains, the files carry personal data and the vendor is not cleared to hold it, the terms exceed the release policy, or personal data was asked to leave without a watermark and a one-time code. The wizard asks all six first, so a refusal means a request that did not come from it or one that raced a change. Until now every one of them answered the same generic conflict, the route could not tell them apart, and none left a row — the trail showed every release that went out and nothing about the ones the server stopped. createTransfer now reports which rule refused, and POST /api/transfers writes a Release refused event at blocked severity, filed under that rule's own control (A.5.16, A.5.20, A.8.12, A.5.34 or A.5.1). The sender sees the same message as before. A request refused for being malformed — a file that does not exist, a project the sender was never granted — leaves no row, because it is not a control holding. Nothing reaches the register for a refusal, so the row names the vendor but no release, and the reports page's per-vendor column counts it as unattributed. The demo's reducer records its two refusals the same way, worded by the same function, and the demo seeds one so the row can be seen. The build fails if any rule stops naming itself, if createTransfer stops passing the rule on, or if the route stops recording it.
Filed under the control the rule enforces — here the domain lock's A.8.12 — and counted under Blocked attempts on the reports page.
A front door for somebody who has never seen it
The deployment's address now explains the product before it asks anybody to sign in: what it is for, how one release moves from a file to a supplier and back, what it enforces, where it stops, and how it maps to ISO 27001.
- BuiltSecureTrace carries the Braintree tree as its mark, in black and white, in place of a stock shield icon.
Every place the product named itself — the sidebar, the sign-in page, the recipient portal's header, the public site's header and the landing page's journey diagram — drew a generic shield from the icon set, the same shield that elsewhere means "one-time code checked". It is now the Braintree tree, drawn in one colour that follows the theme: white inside the dark tile in light mode, dark inside the light tile in dark mode. The logo's greens and orange are left out on purpose, because colour on these screens is kept for meaning and a green mark in every corner would read as a success state. The favicon is the same tree and turns white when the browser is in dark mode; older browsers and an iPhone's home screen get it as a dark tile with a white tree, which shows up on any background. The shields that mean something — the policy chip, the verification card — are unchanged.
The same tree sits in the sidebar, the recipient portal, the public site's header and the browser tab. - FixedThe public site offers Try the demo again, linking to the demo site, with Sign in kept for staff.
The landing page's button read Try the demo only on a deployment running in demo mode. The public site holds real data and can never run in demo mode, so there it read Sign in — the one thing a prospective buyer cannot do. A new DEMO_SITE_URL setting names the demo's own address, and where it is set the button links to that site's sign-in, where the visitor picks a persona; a quieter Sign in stays beside it in the header for the deployment's own staff. Only an http or https origin is accepted, because the value becomes a link. The page learns it, and whether the contact form exists, from a new public GET /api/site that answers from settings alone.
On securetrace.braintreex.com, Try the demo now opens the demo site's persona sign-in; staff use Sign in beside it. - FixedOn a wide screen, the landing page's scrolling feature strip no longer runs out before the edge.
The strip ran the full width of the window. On a wide screen that is wider than one pass of its pills, so the loop showed its end — an empty stretch on the right — before the second copy scrolled in. It is now capped at the page's content width, the same as the header and every section below it, so it lines up with them and is always longer than the space it scrolls through.
The strip now stops at the same edges as the sections around it, and fades out at both ends. - BuiltThe changelog wears the same header as the landing page, so the two read as one site.
The changelog kept the light header it was built with, so following Changelog from the landing page arrived somewhere that looked like another product. Both pages now use one header component: the wordmark, links to the landing page's sections, the changelog itself, Contact us where the deployment takes enquiries, and one call to action. That last one is a slot rather than part of the header, because the landing page's button asks whether somebody is signed in and the changelog is held to reading no session at all — so it passes a plain Sign in link, and a build check fails if the shared header ever reaches for the session itself. The section links point at /product rather than /, which is the dashboard to anybody signed in and the sign-in form on a deployment without the landing page. The changelog's content now spans the same width as the header, so the title lines up under the wordmark.
The section links lead back into the landing page; Changelog is lit because it is the page you are on. - BuiltA client's own server still opens on the sign-in page; the landing page is switched on for the public site and the demo.
The landing page greeted every signed-out visitor on every deployment — including a client's own server, where the people arriving are its staff and a pitch for the product they already own reads as a mistake. It is now off unless the deployment sets LANDING_PAGE=1: the public site and the demo set it (the demo's compose file does so itself), and every other deployment sends a signed-out visitor at / straight to sign-in, as before. Off is the default on purpose, so a client upgrading never acquires a sales page on their internal tool. The page itself stays reachable at /product anywhere, and a build check fails if the default is ever flipped.
What a signed-out visitor sees at / on a deployment without LANDING_PAGE=1 — a client's own server among them. The landing page stays at /product. - BuiltVisitors can send an enquiry from the landing page, on the deployments that want them to.
A Contact us button in the landing page's header, and Talk to us beside the closing call to action, open a short form — a name and work email, and optionally their company, what they are interested in and a message — that is emailed to the address in CONTACT_EMAIL, with the visitor as the reply-to so an answer goes straight back to them. The address comes from the deployment's settings and never from the request, which is what makes a public form that sends mail safe to leave open: the worst it can do is fill one inbox, and it is limited per address and across the whole deployment, carries a hidden spam trap, and refuses a line break in any field that reaches an email header. With CONTACT_EMAIL unset — the default, and the right setting for a client's own server — the buttons never appear and the endpoint answers not found, so a client's staff are never offered a form that emails the vendor. It is the one message sent on a demo deployment too, because its recipient is the operator rather than anyone the demo must not reach. Nothing is stored and no audit record is written: an enquiry is not a governed act, and the email is its record.
Contact us in the header and Talk to us at the foot of the page open the same form. On a deployment with no contact address, neither button appears. - BuiltThe landing page speaks to any sensitive file, with engineering drawings as one example among several.
The page described the product as a way to send engineering drawings to suppliers, which undersold it: nothing in the controls is specific to drawings, and a label, an approval, an expiring link and a record protect a contract or a payroll export in the same way. The copy now says sensitive files and outside partners — suppliers, contractors and others — and a new What you can send section shows six kinds of file: engineering and design, contracts, finance and pricing, people and HR, customer data, and plans and tenders, each with an example file carrying the product's own classification badge. The two cards holding personal data say the extra rule that applies to them and nowhere else: such a file only goes to a partner cleared to hold personal data, always with a one-time code and a watermark. The recipient is a partner on this page and still a vendor in the workspace, whose registry keeps its word. The section that listed the stack — a container, PostgreSQL, S3 storage — is now Getting started, written for the buyer: it can be hosted for them or installed on their own servers, set up and trained with their team, with the stack reduced to one line for their IT people.
Six kinds of file, each carrying the product's own label badge. The two personal-data cards state the extra rule that applies to them. - BuiltThe landing page is written in plain words, shows how it works as a picture, and answers the common questions.
The first version of the page spoke the workspace's language — releases, classification ceilings, disposal obligations — to somebody who had never seen the product. It now says send, label and confirm they destroyed it, keeps the Annex A codes to the ISO band, and follows the order a first-time reader's questions arrive in. Five new sections: a strip of what it does that scrolls sideways; a Today versus With SecureTrace switch that flips six plain answers, once on its own and then at the reader's hand; the five steps as a diagram with a file travelling between your team, SecureTrace and the supplier; a card per person who uses it — engineers, managers, security and audit, and suppliers; and the two ways to buy it, the file organiser alone or with supplier sharing. An FAQ closes the page and is where the limits are stated in plain words: a downloaded copy is out of reach, forward and print are refused in the portal only, an open supplier session outlives a revocation by up to thirty minutes, scanning needs a scanner connected, and no product can be ISO certified. Cards light up under the pointer and a hairline across the top fills as the page is read. Under reduced motion nothing plays on its own, the strip becomes a still list and every control still works.
How it works: one file travels from your team to the supplier while the five steps light up in turn. Hovering holds it; every step is a button. - BuiltOpening the address signed out shows a landing page that plays one release from the file to the audit trail.
A signed-out request for / used to redirect to the sign-in form, so somebody given the deployment's address met a login box for a system they had no account on. The proxy now rewrites it to /product — the address stays /, and signed in / is still the dashboard, so no link had to move. The page is written for a prospective client: the problem, the five acts of a release, eight controls that refuse rather than suggest (including a hash chain that visibly breaks when a row is edited, and a ceiling-and-floor band), the ISO mapping's own counts computed from the mapping rather than typed, and — on the page, not in a footnote — what the product does not do: the watermark is a viewer overlay, a downloaded copy is out of reach, and forward and print are refused in the portal only. Its one call to action reads the session's shape and nothing else: Sign in, Try the demo on a demo container, or Open your workspace. It is public on the changelog's footing, and a new build check holds that: the rewrite matches / exactly, nothing under the page imports the data layer or the audit writer, and the store is read for three session fields only. Animation goes through five shared primitives and switches off under reduced motion; the page stays greyscale, and the only colour on it is inside the product's own components.
The hero plays the demo's own release through five stages, drawn with the same badges, policy chips, watermark and audit timeline the workspace uses. Hovering holds it; every stage is a button.
Changing a policy or an undertaking takes two people, and the policy is reviewed every year
The rules every transfer is held to, and the undertakings staff and recipients accept, can no longer be changed by one administrator alone; the product keeps the planned review an auditor asks about, a material change to an undertaking is asked of everybody again, and staff give theirs before they take a file out as well as before they put one in.
- TightenedStaff accept their undertaking before they first download, preview or send a file, not only before their first upload.
The staff undertaking was asked before a person's first upload and nowhere else, so a member of staff who only ever downloaded files or raised releases was never asked it — the one limit that kept acceptable use short of fully met. It now stands in front of every act by which a member of staff moves information in or out. On the server, seven handlers ask for it before reading a body or resolving a record: the three upload endpoints as before, and now a version's content endpoint (which serves both Download and Preview), the ZIP archive of a folder or selection, the raw object read, and raising a release; each refuses a person who has not accepted with the same sentence, and the deploy token stays exempt. On screen, a form shows the undertaking in its place — /upload and a new revision as before, and now the send wizard, which opens on it before its first step — and a button that takes bytes out opens it in a dialog and carries on as soon as it is accepted. Reading the tree or the register and deciding a release are not gated, since they move no bytes. The demo's reducer refuses a new release without it too. The build fails if any of the seven handlers stops asking, asks after reading the body, or if a download button or the shell's dialog goes. With this and two-administrator publishing, A.5.10 moves to fully met; the wording of both undertakings remains the organisation's to propose.
What Download folder opens for somebody who has never accepted. Accepting carries straight on with the download; closing it does nothing. - TightenedA new version of either undertaking is proposed by one administrator and published only when another approves it.
Publishing a new version of the staff upload undertaking or the recipient handling terms was one administrator's act, and through a material change one click could re-open every acceptance on the register. Both now follow the release policy's arrangement. POST /api/terms and POST /api/undertaking are gone; a new version is proposed at …/proposals with the wording, a written reason and the choice between a correction and a material change, and published only when a different administrator approves it at …/proposals/[id]. The approver approves the wording and the materiality together — one who disagrees about materiality rejects with the reason, rather than quietly changing a decision the proposer never saw. Migration 0032 holds the rule in the database for both documents: a self-approval is refused by a check constraint, one proposal is open at a time per document, a decided proposal can never be edited or deleted, and a constraint trigger refuses at commit any new version that no approved proposal names — so a version inserted from a script or psql fails too, not only one from the screen. The second-person rule is now one function shared by the policy and both documents. Each proposal, rejection, withdrawal and publication is on the trail, and a publication names both people. A deployment where only one administrator can sign in is told so on the card. A.5.10 stays met with a stated limit, now narrower: the staff undertaking is asked before a person's first upload, so a member of staff who only downloads or raises releases is never asked it.
The approver sees whether it is a material change beside the wording and approves the two together. Signed in as the proposer, the card offers only Withdraw. - TightenedA change to the release policy is proposed by one administrator and takes effect only when another approves it.
The Save button on Policies wrote the four release rules on one administrator's say-so. It is now Propose change: the whole set goes in with a written reason, the table keeps showing the rules in force, and nothing moves until an administrator other than the proposer approves it at PATCH /api/policy/proposals/[id]. PUT /api/policy is gone, and the build fails if any write verb returns to that route or if policy_rules is updated anywhere but the approval. The second-person rule is held three times over: the same function gives the sentence on the screen, in the demo and in the adapter, and a check constraint in migration 0030 refuses an approval by the proposer whatever the code did. A rejection needs a reason; only the proposer can withdraw; one proposal is open at a time and the table is locked while it is; a decided proposal can never be edited or deleted. Each act is on the trail, and an approved change still files one row per classification that moved, now naming both people. A deployment where only one administrator can sign in is told so on the card, since a proposal there can never be approved. The demo gains a second administrator persona, Kavitha Raman, with a proposal of hers waiting for Farid to approve.
Signed in as the proposer, the same card offers only Withdraw, and says why. The database refuses a self-approval even if a screen offered one. - BuiltThe release policy has a planned review, and the screen says when the next one is due.
ISO 27001 asks for a policy reviewed at planned intervals, and the mapping used to hand that to the wider ISMS. The interval is now twelve months, fixed rather than a setting so one administrator cannot quietly lengthen it. An approved change counts as a review, written in the same transaction as the approval; a year with no change needs one recorded, with a note of what was reviewed, through POST /api/policy/reviews. Reviews cannot be edited or removed. The card measures the due date against the server's clock at the moment of the read, and a deployment that has never reviewed its policy says so rather than calling it overdue. README's client setup checklist now includes appointing two administrators and recording the first review. With both halves in place, A.5.1 moves to fully met.
Due soon inside thirty days, overdue after. Approving a change moves the next review a year out. - BuiltA new version of an undertaking can be published as a material change, and then everybody accepts it again.
Staff accept the upload undertaking before their first upload and recipients accept the handling terms before any download, and until now a later version was never asked again — deliberately, because re-asking on every publication would lock every uploader out over a typo fix. Publishing either document now asks the administrator to choose between a correction and a material change, with neither selected and both consequences stated; the server refuses a publication that does not say. A material version stops every acceptance of an earlier one counting: staff are asked again before their next upload, and recipients before their next download of a file they had already accepted terms for. Which version still binds is one SQL function per document in migration 0031, asked by the upload gate, the download gate, the portal's view gate and the acceptance writes alike, and the build fails if one of them stops asking. Re-acceptance writes a second row rather than moving the first, so the record of what somebody agreed to before survives. A correction re-asks nobody. A.5.10 stays met with a stated limit: the staff undertaking covers uploading rather than all use, and nothing evidences management approval of the wording beyond the administrator who published it.
What a member of staff sees at their next upload. Accepting writes a second acceptance; the first stays on the record.
Letting the hourly sweep act, and seeing that it runs
The scheduled job that watches the register can now say when it last ran, can take two actions on its own where an administrator allows it, and seals the audit trail somewhere the database cannot reach.
- BuiltThe deployment says when its hourly sweep last ran, instead of leaving it to be checked on the server.
Withdrawing links when an agreement lapses, recording monitoring findings, opening destruction obligations and rescanning stored files all depend on one scheduled job, and until now the only way to know it was running was to read the host's crontab and log over SSH — the policies screen said as much. Every run that takes the sweep's lock now records itself as the last thing it does, in a new sweep_runs table. /api/health reports sweep.state as current, stale (two missed hourly runs) or never, with the last run's time and outcome, and null when it cannot ask rather than never. It does not make the probe unhealthy: a stopped schedule is the host's crontab, and restarting a container over it would change nothing. The same reading is the first row of a new Automatic responses card on Policies → Enforcement, and of the Setup checks card on an administrator's dashboard, measured against the server's clock at the moment of the read.
If the schedule stops, this row turns amber after two missed runs, and says so separately when the sweep has never run at all. - BuiltThe sweep sends destruction notices on its own, and an administrator can let it withdraw a release on a monitoring anomaly.
Two things the sweep deliberately stopped short of are now the deployment's choice rather than the product's. With the first switch on, when the sweep records a new download or portal-view anomaly against a recipient it revokes the live release the finding names, ends that recipient's sessions, and files Access revoked — monitoring anomaly under A.8.16; a finding about an internal download names no release and withdraws nothing. With the second on, when the sweep opens a destruction obligation it also emails the recipient, exactly as the disposal queue's Send does: the obligation is recorded first, the address comes from the register, and a provider failure is recorded as not despatched for the queue to re-send. Both are set through PUT /api/policy/automation, administrators only, the deploy token refused, and each switch that moves is written to the trail under its own control. Notices start on, on a new deployment and on one upgrading, because they ask a supplier to do what their agreement already obliges. Withdrawal starts off, because the rules it acts on (more than three downloads in ten minutes, two addresses in an hour) also describe a vendor downloading a four-file release in one sitting. Each carries the moment it was switched on, stamped by the database, and the sweep acts only on findings and releases after it. That bound is what makes notices on by default safe — without it the first run would email every supplier with an old expired release at once. The build fails if the sweep calls the mailer or revokes on a finding without asking that bound first. A.8.16 moves to fully met, because acting on a finding is now something a deployment can configure; A.5.11 stays met with a stated limit, since no system can verify a recipient destroyed their copies. The recipient portal's line claiming that unusual activity automatically suspends a link, which had never been true, now says the activity is recorded and may end the release.
How a new deployment starts: notices on, withdrawal off. Each acts only on what comes after the moment it was switched on, so neither reaches back into the backlog. - BuiltAn administrator's dashboard lists what the deployment still needs from whoever set it up.
Six things are configuration on the server rather than anything the product can set for itself, and each one leaves a control the compliance page calls met with nothing exercising it: a sweep nobody scheduled, a bucket without S3 Object Lock under the audit checkpoints, no malware scanner, no audit signing key, no encryption key, and no mail provider. Each was checkable only over SSH, so it was checked once at setup and never again. A Setup checks card on an administrator's dashboard now shows all six, from GET /api/deployment (administrators only, since asking the bucket about Object Lock is a call to the storage provider). A check the server could not ask is shown as a question mark, never as passing. The same list is in README as the checklist for setting up a client deployment, so the next person who sets one up is reminded by the product rather than by memory.
Amber rows name the README section that fixes them. When all six pass, the card shrinks to one line. - BuiltThe audit trail is anchored outside the database, so rewriting it is caught even by somebody who controls the database.
The trail was already append-only and hash-chained, so an edit made round the database's guard broke every link after it — but somebody with full control of the database could recompute the chain from the row they edited and leave it internally perfect, which is why A.5.33 was met with a stated limit. Every hour the sweep now seals the newest row's position and hash into the object store as audit/checkpoints/<seq>-<hash>.json, signed where the deployment holds an audit signing key, and — on a bucket with S3 Object Lock — in compliance mode, which nobody, the account's root user included, can overwrite or delete before its retention ends (AUDIT_CHECKPOINT_RETAIN_DAYS, seven years by default). Every audit extract compares the chain with every checkpoint ever sealed and says what it found, in the toast, in three response headers and in the extract's own trail row. It compares all of them rather than the newest because a person who rebuilt the chain can add a checkpoint that agrees with them, and only the older ones they cannot remove contradict it. The sweep runs the same comparison before sealing, refuses to seal over a mismatch, and fails its run so the host's log shows it within the hour. With a default retention on the bucket the uploaded files are write-once too. On a bucket without object lock the checkpoints are still sealed and compared, and every extract says they are not object-locked. A.5.33 moves to fully met; README's storage section has the four steps to turn object lock on.
The last clause is new: the chain was compared with every checkpoint sealed into the bucket, and the bucket keeps them under object lock.
Answering somebody who asks what is held about them
A person can ask what an organisation holds about them. An administrator can now answer for this product with one file, built the same way every time.
- BuiltAn administrator can export everything the deployment holds about one email address, as a single sealed file.
Answering the question used to mean knowing the schema and writing the queries by hand, across tables the product deliberately keeps behind three separate modules. Settings → Roles & permissions now has a Subject access card that asks for an address and saves a JSON file, through POST /api/subject-access, for administrators only. It covers both kinds of person the product knows. For a member of staff: their directory record, the projects granted to them and through their department, their upload undertaking, and their live sessions with the device and address each came from. For a vendor contact: every contact record carrying the address, the releases naming it, the handling acknowledgements, the markups returned, and the portal sessions and outstanding codes. For both: every audit row naming the address, and any monitoring finding against it. What a member of staff is attributed on, such as files owned or releases raised, is counted rather than listed, because those records are the organisation's engineering data, not the person's. The file carries, inside it, what the report cannot look for: names inside files or typed into free text, spent sign-in links, copies outside the system, and erasure, which the append-only trail does not allow. It is sealed like the audit extract, with a digest recorded in the trail and a signature where the deployment holds a signing key, and it is recorded against the person it is about, so the next report on them lists this one. The address travels in the request body, never the URL, so access logs do not become a list of who was asked about. A.5.34 stays met with a stated limit, for the parts no query can reach.
The counts say what was found. The list below them says what no query here can find, and it is written into the file as well.
Clearing the records every signed-in person could read
A record with no project code has always been readable by everybody signed in, and nothing could stop one being that way once it existed. An administrator can now file it under a project.
- BuiltAn administrator can file a record that has no project code under a project, or move a record to a different project.
Access to the file tree follows project membership. A record carrying no project code has no project to follow, so every signed-in person can read it. New ones stopped being possible when the project code became required on every upload, folder and release, but the ones already there could only be fixed with direct database access, where nothing is recorded. The Permissions tab of a record now offers an administrator File under a project, through PUT /api/files/[id]/project. The same control reads Change project on a record already in one, because a project code decides who may read a record, and one filed wrongly with no way to correct it would be permanent. It never files a record back to having no project code, since that would open it to everybody. A folder holding another project's records is refused, by name, rather than filed with everything inside it: a folder's project also decides who may save into it, so filing the demo's Design Library under BRAND would stop the HMI-2027 team saving beside their own work. Nothing beneath a folder moves. Each filing is recorded with both project codes under A.8.3, at warning severity when a record leaves a project or goes to one nobody holds yet. A.8.3 stays met with a stated limit: the product can now clear these records, but it does not decide which project a legacy record belongs to.
A folder shared by two programmes stays a container. It is not handed to either of them.
A control mapping that reads as what it is
The compliance page scored its own controls in words that made the product sound as if it were failing half of them. It now says what each status means in plain terms, and leads with the answer a reader came for.
- FixedThe compliance page says the product enforces 26 of the 27 controls it maps, and names whose the last one is.
The three statuses read Demonstrated, Partial and Production scope — “a real requirement this product does not meet”. None of that was untrue, and all of it read as a score with a failing column, to the product’s own owner as much as to anybody else. The statuses are now Fully met, Met with a stated limit, and Organisation’s part, each with a line saying what it means, and a sentence above them gives the answer first: 26 of the 27 controls are enforced by the product, 13 in full and 13 up to a limit that each row names. The one that is not is A.5.21, which is about the platform’s own suppliers — the hosting, the database, the storage, the mail provider and the scanner — and is met by the contracts and supplier reviews of whoever runs it, not by software. Its description now says that rather than “not shown here”. One limited control, A.5.32, had never said what its limit was, and it does now. No claim got wider: every limited control names its limit in its own row, and the count behind each status is unchanged.
The same three counts as before. What changed is what the words beside them tell a reader. - FixedAudit extracts can be signed on a Docker deployment, and key rotation can be configured there — neither could before.
Three settings were read by the server, documented in the example configuration and described in the operating guide, and passed to the container by no Docker Compose file: the audit signing key, and the two settings that let the storage encryption keys be rotated. Compose hands a container only what its configuration lists, so on every Compose deployment audit extracts were unsigned and rotation was impossible whatever had been configured — and nothing on any screen looked wrong, because “unsigned” is a legitimate answer and a single encryption key still encrypted. All three are passed through now, and the build fails if a documented setting reaches no container again. With the signing key able to arrive, A.5.28 Collection of evidence moves to fully met: an extract is signed wherever the deployment holds a key and says “unsigned” where it does not, which is the footing malware scanning already stands on for a deployment with no scanner. What the signature cannot say — that the rows were never rewritten before the extract was made — remains stated under A.5.33.
Evidence collection moved to fully met once the signing key could actually reach the server.
A notifications screen that says what actually happens
The Notifications tab offered three switches and a Save button, and saved nothing — and two of the three were for emails the product has never sent. It now states what you are emailed and what you are not, for your role on this deployment.
- FixedThe Notifications tab states what you are emailed, instead of offering switches that did nothing.
The tab showed three switches — expiring links, approval requests and decisions, and blocked or unusual activity — and a Save button that showed a confirmation and stored nothing. Nothing in the product emails an expiring link or an anomaly, so two of the switches governed messages that do not exist, and one promised to watch for “unexpected geography”, which the monitoring deliberately cannot work out. The third message is real, but whether approvers are told is a setting for the whole deployment rather than for one person, so switching it off in the tab changed nothing about the email. The tab now lists, for your role on this deployment, what you are emailed and what you are not, and where to find the things that are not emailed. Approvers and administrators see the approval-request row; senders do not, because it is never theirs. If the deployment has switched approval emails off, or has no mail provider, or is a demonstration, the tab says so. There are no switches, and the card says why.
Two of the rows used to be switches for emails that do not exist. Now they say so, and say where to look instead.
Refusals per supplier that add up
The reports screen could show how many attempts were refused against each supplier only in the demo; a real deployment showed nothing there. It shows them now, and says how many refusals belong to no supplier, so the column and the headline agree.
- BuiltBlocked attempts are counted per supplier on a real deployment, with the remainder stated.
The per-supplier refusal count was left out of real deployments on purpose. Most refused attempts are not about any supplier — a refused sign-in, a refused permission check — so a column counting only the ones raised against a release would understate by a margin nobody could see, right beside a headline total that did not. That argument is what shaped the fix. The database now counts refusals by the supplier of the release they were raised against, in one query over the whole trail, and counts the ones it cannot place as well. The card shows both, so the column plus the unattributed line is exactly the Blocked attempts figure. The count takes no filters, so it cannot be used to ask questions about a particular supplier or period, and it is readable by approvers and administrators only — the people the reports screen is for — because a supplier’s refusal count is the kind of record the registry already keeps from senders. Like the tile, it covers all time on a deployment.
One plus four plus the one tied to no release is the six on the Blocked attempts tile. Taken in the demo; a deployment gets the same arithmetic from one query.
Watching the viewer as well as the downloads
Opening a released file in the recipient portal is deliberately unmetered, and nothing watched how fast it happened. It has a monitoring rule of its own now, with its own threshold, published beside the download rules.
- TightenedPaging through a release in the portal far faster than anybody reads is flagged.
A recipient can open a released PDF or image in the portal without spending a download, which is the point of a view-only release. The monitoring rules only ever looked at downloads, and deliberately: their thresholds — more than three in ten minutes — are set for somebody taking copies, and would report somebody re-reading a drawing as an incident. So the viewer went unwatched. Views now have a rule of their own: more than twenty in ten minutes by one recipient, which is one every thirty seconds, sustained — the pattern of somebody capturing a release screen by screen rather than reading it. The numbers are a judgement and are published on the Policies screen beside the download rules, from the same constants the detector applies. A finding says how many views and of how many files, and is never counted as downloads. The scheduled sweep records each one once per episode, the way it does the download findings. There is no second rule about views from several addresses, because a recipient moving between networks while reading does exactly that. The demo carries a seeded burst so the rule has something to find.
The third finding counts views, not downloads — nothing was taken, and the release’s own counter says so.
Holding staff to the standard asked of suppliers
Every supplier accepted a written undertaking before the server would release a byte to them, and nobody inside the company was ever asked anything before putting a Restricted drawing into the system. Staff now accept their own undertaking, once, before their first upload.
- TightenedStaff accept an upload undertaking, once, before their first upload.
The recipient’s handling terms became a governed document some time ago: versioned, impossible to edit once published, and accepted per file before any download. The staff side had nothing — the mapping said so in as many words. There is now a second document of the same kind, the staff upload undertaking, and a member of staff accepts it once before their first upload. It is asked of a person rather than of an upload, because a paragraph put in front of every upload is one people learn to click past. All three ways of putting bytes into the system — a new file, a new revision, and the raw object endpoint — refuse a person who has not accepted, and ask before the upload is read rather than after. The acceptance records the version that was on screen and can never be edited or withdrawn. It is accepted from the session alone: the request carries nothing, so nobody can accept on somebody else’s behalf. An administrator publishes new versions on the Policies screen. A new version is not re-asked of anybody who already accepted, because re-asking on every correction would lock everybody out of their own work. Nobody was marked as having accepted by the upgrade either — an acceptance is something a person did, so everybody is asked at their next upload. The demo asks too, and records the answer.
Where the upload form used to be. One click, recorded against the person and the version they read, and not asked again.
A trail that can tell it was rewritten
The audit trail refused edits, and anybody able to switch that refusal off could edit a row and switch it back on with nothing to show for it. Every row now carries a fingerprint of itself and of the row before it, and every audit extract checks the whole run and says whether it still holds.
- TightenedThe audit trail is hash-chained, and every extract says whether the chain held.
The trail has always been append-only: a database trigger refuses any update or delete, and the product offers no way to revise a row. What that could not do was notice when somebody with database access disabled the trigger, changed a row and re-armed it — the edited row was indistinguishable from one that had always said that. Each row now carries a SHA-256 over its own fields and over the row before it, computed by a second trigger rather than by the application, which overwrites whatever a statement supplies. Changing one row breaks every link after it. Every audit extract walks the chain with the database’s own function, checking each row against itself and against its predecessor — the first catches a careless edit, the second one where the tamperer recomputed that row’s fingerprint, and a deleted row — and reports what it found in the response, on screen, and in the trail row the export writes, which also names the first row that does not link. It is checked over the whole trail rather than the rows being exported, because a chain links consecutive rows and a filtered extract is not consecutive. Rows written before today were chained by the upgrade, which seals them as they stood this morning and claims nothing about the day before. Nothing copies the newest fingerprint outside the database either, so somebody with complete control of it could still rebuild the chain from the row they edited — which is why the control stays partial, and why a copy of that one value kept somewhere else is what would close it.
One row edited directly in the database with the guard switched off and back on again. Nothing on /audit looks any different; the next extract says so.
Who may hold a person’s name
The personal-data rule decided how a file full of names left the building and never asked who it was going to. A supplier is now cleared to receive personal data or is not, an administrator records the basis, and a release naming an uncleared vendor is refused before anybody fills in a policy step.
- TightenedA vendor has to be cleared to receive personal data, and the send wizard says so before the work.
The rule on a release carrying personal data governed the terms it went out under — an identity watermark and a one-time code, whatever the classification — and said nothing about who was receiving it. So a folder of model releases could go to a stamping-die supplier with no data-processing agreement, wrapped perfectly, with every screen correct and every other control satisfied. A vendor now carries a personal-data clearance an ISMS administrator records with a written basis, and a release naming a file flagged as holding personal data is refused unless the recipient holds one. It is a separate rule rather than a fifth setting on the release, because no combination of settings satisfies it — a rule the two switches could clear would be the first rule under another name. It is asked before the release policy, so somebody is not sent to shorten a window that would not have helped. The flag itself travels to every role, because a sender refused by it has to be able to look up what refused them; the assessment behind it sits with the review findings and the ceiling exception, which an approver and an administrator read. Withdrawing a clearance refuses every release raised from that moment and takes no written reason — the safe direction is not made to justify itself. No existing supplier was cleared by the upgrade: a clearance is an assessment somebody performed, and guessing one from the releases a vendor has already had would file an assessment nobody made.
The release is watermarked, behind a one-time code and correctly labelled. It is still the wrong recipient, and that was the question nothing asked.
What the recipient actually receives
A release recorded which files it carried and not which revision of them, so the drawing a vendor downloaded was whichever one was newest at the moment they asked — including a revision uploaded after the approval. The register names the revision now, and the screens on both sides say which one.
- TightenedA release names a revision, so revising a drawing no longer changes what was sent.
Until now the register recorded which files a release carried and nothing about their revisions, so the recipient's download resolved whichever version was newest when they asked. An approver decided a release by reading the drawings in front of them and anybody who may add a version — which is every role — could change what the vendor got afterwards, silently, with every screen still correct. Two recipients of one release, a day apart, could hold different bytes. The revision each file was at is now written into the register when the release is raised, in the same transaction that works out the release's classification, and no request can set it — the fourth field on that list beside the status, the classification and the expiry. Both recipient routes resolve that revision: the download and the portal's viewer, so a look and a copy cannot disagree either. The portal shows the revision beside the file name; the transfer page shows it against the file's current one, and says so when the record has moved on. Existing releases were pinned to whatever was newest at the moment of the upgrade, which is what they were already serving, so nothing a recipient sees changed as it was applied.
The register's answer beside the record's. The sender who just uploaded a correction is told the vendor still has the old one.
Saying which seal it is
The audit extract has carried a cryptographic signature since the week it was built, and six places went on saying it could not — the spec, this record, four code headers, and the card a person reads on the way to handing a file to an auditor. The code was right the whole time; everything describing it now says what the trail row has been saying all along.
- TightenedThe extract says whether it was signed, and names the key that signed it.
Every extract is sealed with a SHA-256 written into the append-only trail, and a deployment holding a signing key adds a detached Ed25519 signature that an outsider can verify offline. The server has done both for a week; the browser threw away the three headers that said so, so the only place it was visible was a row in the trail nobody reads at the moment they save the file. The confirmation now names the signing key, in the same words the trail uses — and stays silent about signatures where there is no key, rather than claiming one. The card beside the export said flatly that there was no key behind the seal, which stopped being true the day signing landed and was still on screen five commits later.
The card used to say there was no key behind the seal. There has been one since the week the seal was built.
Finding what you are looking for
The file tree could be searched eight ways and read one way: as a folder you walked into. It now opens on the five questions people actually arrive with, narrows to a project in one click, knows what a PDF is and how big a file is — and every answer it gives is a link you can copy and send. The list itself was straightened out while the work was there.
- FixedThe toolbar above the file list stopped growing every time something was added to it.
A breadcrumb, then a row of five chips for the places, then a strip of project chips: three controls, two rows of chrome and twelve always-visible targets above a list that is often three rows long. Each was defensible on its own and the sum was a header that outweighed its own contents — the failure is additive, because nothing was ever taken away when something was put in. All three answer one question, so they are now one control: a breadcrumb whose first crumb is a menu holding the places and the projects. Closed, it costs one button; open, it is a short list under two headings, which is a shape a reader takes in at a glance and a row of twelve chips is not. What is left on that row is four things in the order somebody reaches for them — where you are, the search box, the filters, and how the rows are drawn.
One row, four controls. The places, the projects and the breadcrumb are the one on the left. - FixedThe file list stopped scattering its stars and hoarding its whitespace.
Three things were wrong with the table and each had the same cause — a layout that let one element decide where everything else went. The **star** sat immediately after the file name, and after the personal-data badge on the rows carrying one, so twelve stars landed at twelve different distances from the left and read as specks rather than as a column; it has its own column now, beside the checkbox, which is the other control on a row that is about the reader rather than the record. The **Name column** had a floor and no ceiling, so `auto` layout handed it every spare pixel and the whole surplus pooled into one dead band between the name and the classification while the five columns to its right stayed at their minimums; it is a share now, so the slack distributes. And the **row** drew a pointer cursor while responding to clicks only inside the name, so three-quarters of it looked clickable and was not — a click anywhere on a row now opens the folder or the file, guarded by where the click landed so the checkbox, the star and the name still mean what they say.
The stars are a column rather than twelve positions, and the gap that used to sit in one place is spread across five. - BuiltThere is a button that copies the link, because a link nobody knows about is not a feature.
The whole question already lived in the address bar, which is the standard idiom and exactly why it was not enough: a reader who has never watched the URL change has no way to learn the screen can be sent to somebody. The control copies the link the page is already on rather than composing a second one, so the two cannot disagree. Its toast names what the link *opens* rather than saying “copied”, because the failure worth preventing is sending a colleague a narrowing you had forgotten was set. Clipboard access is refused in more ordinary situations than its availability suggests — an embedded view, a profile that has not granted it, a document that is not focused — so there is a second route, and when neither works the message says where the link still is instead of apologising.
It says what the recipient will see, not that a copy happened. That is the sentence that catches a filter you forgot was on. - BuiltYou can ask for the big files.
Three bands — under 10 MB, 10 to 100 MB, over 100 MB — rather than a pair of byte fields, because nobody arrives at this screen wanting files between 8 and 40 MB; they want the CAD package rather than the note about it, and a min/max asks somebody to pick a unit and know the distribution before they have seen it. The boundaries divide by 1024, the same base the Size column renders in, so a file the column calls “10.2 MB” cannot land under a heading reading “Under 10 MB”. They are half-open, so every file is in exactly one band — an inclusive upper bound puts a file in two and a gap makes one findable by none. Folders are excluded whenever it is set, for the reason the file-type facet excludes them: a folder’s size is the sum of its contents, so an “over 100 MB” folder may hold nothing over 100 MB. And only the bands the tree actually holds are offered.
The sentence uses the band’s own label, and the band is cut where the Size column’s own arithmetic is. - BuiltThe tree opens on five places, including the files you filed and the files everybody else did.
Google Drive splits a workspace into My Drive and Shared with me. This product cannot: every record on the screen is there because a project was granted, so the reader’s own uploads were “shared with” them by exactly the same act as their colleagues’ — a label drawing that line would name a distinction the register cannot make. What the record does answer is who filed it, so the pair is named for that: Mine and Everyone else, beside All files, Recent and Starred. Neither is a new source of rows. Ownership became a dimension of the query first, settable in the popover as Anyone / Me / Others, because a one-click place may never say something the filters cannot — there would be no way to widen it, see what it did, or get back. It compares the owner’s id and not the name on screen, so two colleagues who share a display name are still two people.
Each place says what it is. “Mine” and “Everyone else” are the two halves of who filed a record — not who it was shared with, which is an act nobody here performed. - BuiltThe projects you hold sit in that same menu, not only behind the funnel icon.
A project is the unit this product governs by — it is what a grant is made of, what decides whether a row is on your screen at all, and the answer to the question a narrowed tree provokes. It was reachable only as a checkbox inside a closed popover. It has its own section in the places menu now, under a separate heading, because the two are not the same kind of thing: a place *replaces* the question and a project *narrows* the one already set, so they compose — Mine, in D55-FL27. Like everything else in that menu it can say nothing the Project facet cannot: one code, or none. The no-project marker is left out of the menu alone, because a row whose entire label is an em dash reads as a broken control rather than as “records filed under no project”; it is still offered in the facet, under a heading that says what the column is.
A project composes with a place rather than replacing it, so the trigger still says Mine and the sentence carries the rest. - BuiltYou can ask for PDFs.
The search matched a file’s extension as free text, so finding every DWG meant knowing to type “dwg” and hoping no folder was called that. File type is a facet now, built from the extensions the tree actually holds so it can never offer one that matches nothing, and folded to one value however the rows happen to be spelled. A folder is excluded the moment it is set, which is the answer rather than an omission — somebody asking for PDFs is asking about bytes. The old Type control, which was folders-or-files, is now called Show: a reader who read “Type” was looking for this one.
The panel is bounded and scrolls now that it carries this many. The count on the trigger and the sentence under the toolbar are what stop a narrowed tree reading as a small one. - BuiltA search is a link you can send somebody.
Every narrowing was component state, so the one thing a person does after finding something — look at this — could not be done at all: the answer was reachable in six clicks and reproducible only by describing those six clicks, which is how a colleague ends up on a different list believing it is the same one. The folder, the order and every filter are in the address bar now, written from the handlers rather than an effect and replaced rather than pushed, so Back still leaves the page. Reading one is total and forgiving: an unknown value falls back to its own default one dimension at a time, because a link is truncated by a mail client and pasted back together, and a screen that errors over a missing ampersand is worse than one showing a slightly wider list. A project the tree does not hold is kept rather than dropped — it narrows to nothing and says so on screen, where silently discarding it would turn a link into a different question.
This is a fresh page load, not six clicks. Three filters and a sort arrived in the URL; the line under the toolbar is how the reader knows what they were sent.
A label answered wrongly can be corrected
How secret a file is, and whether it holds people’s names, were answered once at upload and could never be answered again — while every rule in the product went on reading them. Correcting either is now an administrator’s act, on the record.
- BuiltA classification or a personal-data flag set wrongly at upload can now be put right.
Both fields are answered once, on the upload form, and until now neither could ever be changed. That was not cosmetic: the classification decides which suppliers may receive the file, how long a release link lives, how many downloads it grants and what the download is named, and the personal-data flag is the only thing that forces a watermark and a one-time code on anything below Confidential. So a drawing filed as Internal that was really Restricted stayed Internal for good, and every control went on working faithfully against the wrong value with nothing on any screen to show it. Deleting and re-uploading was not a way out either, because the product refuses to delete a file that has ever been released and refuses one inside its retention period without a written reason — so the records where the label mattered most were precisely the ones nobody could fix. Correcting it is an administrator’s act, recorded with both the old and the new label so that a release raised months earlier can still be read against what it was governed by at the time. Raising a label needs nothing; lowering one, or clearing the personal-data flag, is an exception on the register and takes a written reason, quoted verbatim in the trail at warning severity. A correction to a file that has already gone out is recorded as a warning whichever way it moved, because the copies the supplier holds still carry the old label and nothing here can reach them.
Lowering a label opens the reason field. Raising one does not — the safe correction is meant to be the easy one. - TightenedA folder cannot be raised above the files that are already inside it.
A folder is the floor for everything filed inside it, which is what stops a Restricted project being declassified by making a Public subfolder and moving the drawings across. Moving a folder never breaks that rule, because everything under it moves with it — but relabelling one moves the floor while the files stay put, so raising a folder to Restricted while it holds a Public drawing would leave that drawing somewhere the rule says it cannot be, with no refusal anywhere. The correction is refused instead, and the refusal names the first few offending files and counts the rest, because the remedy is in a different folder and a bare count would send somebody hunting for rows the server had already identified. It refuses rather than raising the contents to match: relabelling a folder’s worth of records under one click, in one person’s name, is exactly the silent reclassification this control exists to prevent.
The refusal names the rows, because the fix is in another folder — and the alternative, quietly raising all of them, is the silent reclassification the rule exists to prevent. - FixedThe permissions table still said anyone could set their own department.
Editing your own department stopped being something anybody could do when departments began granting access to projects — setting your own would otherwise be a way to grant yourself every project that department holds. The server changed with it and the permissions table did not, so the screen the product calls the authority on who may do what described a gate it no longer had. The row is split in two now: your own name stays yours to edit, and your department is an administrator’s, beside your role. The same table gained a row for correcting a file’s label, which is the one thing in the file tree that is an administrator’s while renaming and moving beside it are everybody’s.
Both changes in one table: the new file row under Files, and the department row that had been claiming to be everybody’s since departments started granting projects.
Bytes are scanned on the way out as well as in
Malware scanning covered every entry point and the objects at rest, and still handed a file over unexamined in the moment somebody downloaded it. Every exit now goes past the scanner first, which closes the last of A.8.7’s three gaps.
- TightenedA download is scanned before the bytes leave, and a condemned version stays condemned.
When a scanner is configured, every place bytes leave now puts them past it first: the internal download, a recipient’s download and portal view, a bulk archive, and the raw object read a script uses. A file scanned clean when it landed and rescanned by the sweep since was still served as stored in the moment, so a signature published between two sweeps stopped nothing that left in between; that was the last of the three reasons A.8.7 was partial, and the control is now implemented. The screening sits after the record is resolved and before anything is recorded or spent — a withheld download leaves no row claiming a copy left and costs a recipient nothing off their ceiling — and a verdict reached on the way out is written onto the version through the sweep’s own method, so a download that condemns a file refuses the next one before its object is opened and the sweep does not rescan tomorrow what a download looked at today. It fails closed on the same list as the upload: infected, unreachable, timed out, a non-2xx or an unparseable answer all withhold the bytes, and a scanner that could not answer is reported as an outage with nothing written to the version or the trail, never as a verdict. With no scanner configured nothing is scanned and nothing says it was, which is what the demo is. A build check holds all five exits to it, in order.
The scanner’s answer on the download button. The verdict is written onto the version at the same moment, so the next request is refused before the object is even opened. - FixedThe trail filed the sweep’s rescans and a recipient’s return as employee uploads.
Every malware scan wrote the same sentence, “Upload scanned” or “Upload refused by malware scan”, whatever was actually scanned: the scheduled sweep looking again at a stored object read as an upload by the deployment, and a supplier’s returned markup read as an upload by an employee. With scans now happening at five different points that sentence would have been wrong more often than right, so the event carries where the bytes were, and the trail’s wording and the actor’s type follow from it — a recipient’s scan is filed as a recipient’s act and the sweep’s as the deployment’s. The vocabulary is one table the audit writer owns, so a sixth place bytes could be scanned fails to compile rather than inheriting the wrong words.
One file, three looks, three sentences: scanned on the way in by the uploader, rescanned at rest by the sweep, withheld on the way out once a signature matched. Each row names its own actor rather than filing every scan as an upload.
The file organiser can ship on its own
The product is sold as two halves on one file tree, and a deployment can now be the first without the second — with the transfer screens, the recipient portal and their endpoints genuinely gone rather than hidden.
- BuiltA deployment can be the file organiser alone, with governed transfer switched off.
One environment variable, MODULES=files, and the transfer half is not part of the deployment: the transfer register, approvals, vendors, the release policy, disposal, the recipient portal and every endpoint behind them answer 404 from the proxy before any cookie is read — a page rewrites to one that says which half is missing and points at Files. The rest of the product follows the same table: the sidebar, the ⌘K palette, the buttons on a file and a folder, the dashboard’s tiles and cards, the figures on Reports, the onboarding guide and the walkthrough all stop offering what the proxy would refuse, and the sweep skips the three jobs about releases and says so in its report. The audit trail, Reports and the ISO mapping stay, because an access log and a control mapping are what an auditor asks a document repository for. The switch is a property of the deployment, never a row or a query parameter, and the file organiser itself cannot be switched off.
MODULES=files. The sidebar offers the file organiser and the governance screens, the tiles count the tree rather than a register, and nothing on the screen names a release. - FixedThe permissions table stopped listing permissions a files-only deployment does not have.
Every other surface followed the module table and this one did not, so Roles & permissions still advertised approving a transfer, onboarding a vendor and changing the release policy to a deployment where all three answer 404 — on the screen that is the product’s own authority on who may do what. Two groups and one row are dropped now, along with the record-narrowing rows for the register and the registry and the two disclosures explaining them. A group left with no rows is dropped whole rather than rendered as an empty heading, which would read as a permission set somebody had been denied.
MODULES=files. Two groups and one row are gone, because the endpoints behind them answer 404 on this deployment. - BuiltA bookmarked transfer address on a files-only deployment says what is missing.
The proxy could have answered a plain 404, and for the endpoints it does, in the JSON shape every data endpoint’s 404 already takes. A person following an old bookmark or a link from before the deployment was configured this way gets a page instead: it names the half that is not here and points at the half that is. It is served without a session, like the changelog, because the recipient portal rewrites here too and a recipient holds no internal session; it reads no record, no session and no store, which is what makes that safe.
What /transfers, /vendors or a recipient link resolves to when the transfer module is off — a sentence and a way out, rather than a bare 404 that reads as an outage.
The product tells the recipient
The messages a live deployment cannot run without. A release's link now reaches the vendor from the product itself, rather than from a tab the sender had to copy it out of.
- BuiltEveryone who can approve a release is told when one is waiting.
Every release goes to a decision, and the register has no rule naming whose — so until now a release sat on the register until somebody happened to open the approvals queue. Raising one now emails every active approver and administrator, including one named in the deployment’s configuration who has not signed in yet, since the first approver on a new deployment is exactly that person. The message is sent after the release is recorded and reports a failure rather than refusing the release; the trail records how many people could decide it and how many were reached, and a deployment with nobody to tell gets a warning row rather than silence, because that release will wait for ever. The list of who is told comes from the directory and never from the request. `NOTIFY_APPROVERS=0` switches it off for a deployment that routes decisions another way.
What an approver receives the moment a release is raised. The purpose is the one line the sender wrote; everything else comes off the register. - BuiltThe person who raised a release is told how it was decided.
A sender learned the outcome of a release by going back to the register to look. The approval endpoint now emails them the decision and the decider’s note once it is committed, and the decision’s own row in the trail says whether they were reached and how. It is skipped for the two people it would tell nothing: an administrator deciding their own release, and a sender who has since been offboarded. Like the approval request, it reports a failed send rather than unwinding the decision, and the address is the directory’s record of who raised the release, never a field in the request.
A refusal, with the approver’s note. An approval reads the same way and tells the sender the recipient can now be sent their link. - BuiltA recipient’s link is emailed to them; the sender is no longer the courier.
The endpoint that issues a recipient link answered with the link, and the button on a release opened it in a new tab. That is the demonstration — the sender walks into the portal to show what a recipient sees — and on a live deployment it was the whole delivery mechanism: nothing emailed the vendor, so their link reached them only if the sender pasted it into a mail client by hand, unrecorded. The endpoint now sends the link to the address the release names, through the same mail provider the sign-in link uses, and answers with how it left rather than with the link: on a live deployment the sender never holds it, any more than they hold the recipient’s code. The trail records the delivery beside the issue. A demo deployment keeps the demonstration — the link opens on the sender’s screen and the toast says nothing was sent. The “Copy link” button beside it, which toasted a made-up address and copied nothing, is gone.
What the vendor receives. It names the release, the sender and the vendor, says the code will go to this address and nothing else, and says a password — if there is one — arrives separately, without saying whether there is one. - FixedThe one-time code email named the release by its database id.
Every screen renders a release by its reference and never by its key, and the email carrying the recipient’s code was the one place still doing the opposite: on a deployment it read “Enter it to open transfer t-8f9b46b1-…”. The check that decides whether a code may be issued already reads the release row, so the reference now travels with the verdict and both messages name it; with no register the id is the reference, so the demo reads as it did.
The reference a person quotes, in the one message a recipient reads with nothing else to go on. It used to say t-8f9b46b1-2ce0-4cdd-bfb8-7e03270c005a.
The workspace works as a file organiser
Finding, renaming, moving, starring and downloading in bulk — the everyday half of a document repository, which the product had been asking people to do without. Plus project access granted to a whole department, so a deployment with forty people is not four hundred decisions.
- BuiltA recipient can open a released file instead of only downloading it.
The portal had a panel captioned “Secure preview — rendered server-side and stamped with your identity” that rendered nothing and stamped nothing, and it is now a viewer that shows the image or PDF with the reader’s name drawn over it. Opening goes through its own endpoint and deliberately does not spend one of the release’s downloads: a ceiling bounds copies taken, never reading, and a recipient who downloads once reads the file for ever on their own machine where nothing is recorded. Every open leaves a row in the trail instead. It serves only what a browser draws in place, which is what keeps it from being the download endpoint with the counter taken off.
The recipient’s own name, address and the release reference, drawn over the drawing they were sent. Opening it costs none of their downloads and leaves its own row in the trail. - FixedA view-only release permitted nothing at all, and two screens said otherwise.
View-only is a real setting in the send wizard and the demo ships a release using it. The server refused every byte read for one — correctly, since it is not a download — and there was no viewer, so the recipient saw a filename and could do nothing with it. Both places that mentioned it were wrong in the recipient’s favour: the refusal told them the files “may be opened in the portal” and the panel claimed to be rendering them. The viewer is what makes those sentences true, and the send wizard now warns by name when a view-only release carries a file nothing can draw — which is the one combination that permits neither opening nor downloading.
The one combination that permits nothing: the portal cannot draw a STEP file and a view-only release will not let it be downloaded. Named before the release is sent rather than discovered by the recipient. - BuiltA PDF can be read without downloading it.
The preview box drew images and put a placeholder under everything else, which for a document repository meant the commonest document format in it. PDFs now render in the browser’s own viewer, and SVG stays excluded — not because it is riskier by degree but because it is a different kind of thing: an SVG opened as a document runs script in this application’s origin with your session, where a PDF goes to a separate sandboxed renderer that cannot reach the page. The frame deliberately carries no sandbox attribute: every variant of it was tried against a real PDF and each one rendered an empty box, because the viewer is itself a scripted document — so the attribute would have been a line of code that looked like a control and produced a blank frame. The viewer’s own Download and Print buttons are hidden, so the only download button on the screen is the one that writes a row in the trail.
The browser’s own viewer, with the reader’s name drawn over the page. The viewer’s Download and Print buttons are hidden, so the only download on the screen is the one that writes a row in the trail. - FixedThe identity watermark was invisible over the thing it is meant to mark.
It carries the reader’s name and address so a screenshot still points back to one person, and it had one treatment tuned for a photograph — which over a white document page disappeared almost completely. There are now three, one per thing that can be behind it: the default over the empty placeholder, a difference blend over an image, a flat tint over a PDF. A watermark that reads correctly on the placeholder and vanishes on the content is the control failing exactly where it is needed.
The mark has to survive a screenshot without swallowing the document. There are three treatments now, one per thing that can be behind it — the tuning that reads over a photograph is the one that disappeared over a white page. - BuiltA folder can be downloaded as one archive, with its structure intact.
The archive endpoint refused a folder by name and told you to select the files inside it, which for a folder of twenty across four sub-folders is not an answer. It now takes a folder as well as a selection, and the two produce deliberately different files: a selection is flat, because two files ticked in two folders have no relationship worth reconstructing, while a folder keeps its paths, because the structure is the thing being asked for. Same bounds, same refuse-whole rule, same audit row per file. Reachable from the open folder’s card and by ticking one folder on its own, where the button renames itself so you know which archive is coming.
On the card for the folder you have opened, beside the control that sends it. Ticking a single folder in the list reaches the same request, and the button renames itself so you know which of the two archives is coming. - TightenedA folder’s archive is labelled with the most sensitive thing inside it, not with the folder’s own label.
The first version took the folder’s classification, on the reasoning that a folder sets a floor for everything filed in it. That is true and it is the wrong half: a floor says nothing is *below* it and nothing at all about above, so an Internal folder holding one Restricted drawing — an ordinary, intended state — produced an archive labelled Internal. Understating what is leaving is the one direction that must never be wrong, and it was caught by downloading a folder and reading the filename.
The folder is Internal and one drawing inside it is Restricted, so the archive is Restricted. The toast says “every file in it” because each record leaves its own row — a ten-file archive counted once would under-report the trail by nine. - TightenedThe star on a file can now be set, which for the life of the product it could not.
The column had been in the schema since the first migration. The database stored it, the server read it, the record carried it and the file tree drew a gold star from it — and nothing anywhere ever wrote one: no button, no endpoint, no way at all. The only starred records in existence were two rows in the demo data. It is the third time a switch in this product has stored a value nothing could set, after the release password and the domain lock, and the lesson is the same one: every layer was individually correct, and the half that was missing is the half no layer is responsible for noticing. A star is now a bookmark that belongs to the person who made it, rather than a flag on the record — one reader starring the die layout does not put a star on it in everybody else's tree — and it is a filter beside the other seven, so “the Restricted drawing I starred in D55-FL27” is a question the tree can answer.
The Starred preset, holding the two records the demo opens with and a third the reader added. The star is a bookmark rather than a property of the record — a colleague looking at the same tree sees their own. - BuiltEvery column in the file tree sorts.
Name, classification, project, owner, modified and size — the six the table was already rendering and none of which could be ordered by. All six rather than the obvious four, because a column that does not sort is a header somebody clicks once before concluding the table is broken. Folders stay above files whichever column is picked and whichever direction, since a folder is the row you click to navigate and interleaving the two by size makes the one you want unfindable; ties fall back to the tree's own order, so rows that are equal do not shuffle between renders. Re-sorting returns to the first page, because somebody who sorts by size is asking for the largest file and it is at the top.
Sorted by size, largest first — and a folder’s size is the sum of everything under it, which is the figure the column already showed. Folders stay above files whichever column is picked. - BuiltThree ways to read the tree, one click each.
All files, Recent and Starred. Each is only a shortcut through the filters and the sort that were already there — Recent is every file newest-first, Starred is the star filter — so there is nothing a preset can show that the controls cannot, and the lit one is worked out by comparing the current question rather than remembered, which means narrowing a preset by hand simply drops the highlight instead of leaving a chip lit over a list it no longer describes. Recent is deliberately not a time window: “the last 7 days” is already a filter, and a preset that set one would report a quiet fortnight as an empty repository.
Each chip is only a shortcut through the filters and the sort beside it, so there is nothing a preset can show that the controls cannot — and the lit one is worked out from the current question rather than remembered. - BuiltFiles can be searched by more than their name.
The box used to compare what you typed against two fields, and only against files — so a folder could not be found by name at all, and the Owner, Modified and Size columns the table renders could be read but not asked about. It now searches the name, the project code, the owner and the extension, and takes six more narrowings behind a Filters button: classification, project, owner, record type, a personal-data flag and a modified-date window. Two words are an “and” rather than a phrase, so “die layout” finds the drawing and “layout die” finds it too.
One name, five records, four projects — and a *folder* among them, which the old box could not return at all because it searched files only. The footer counts them as “1 folder · 4 files”. - BuiltA narrowed list says so, in a sentence, above the rows.
Six of the seven filters live behind a popover that is shut by the time anybody is reading the list, so without it a filtered tree is indistinguishable from a small one. The Filters button carries a count and the page carries a line — “Showing Restricted · project D55-FL27” — for the reason the pager says how many of how many. The empty state follows: “this folder is empty” is a statement about a folder and is never shown for a filter that matched nothing.
Every value offered is one the tree actually holds, so no filter can be set to something that matches nothing. The classifications are in rank order rather than alphabetical — the order they are read in everywhere else. - BuiltA record can be renamed, and a rename cannot change what the file claims to be.
The extension is fixed, because a download is served as the type the record says it is — renaming a drawing to .pdf would hand every later downloader a DWG announced as a PDF. The dialog says so before somebody types over it rather than after. The trail keeps both names, so every earlier event about that record still reads. A released file can still be renamed: refusing would make a typo permanent for the life of the register, and the pressure would go somewhere with no record at all.
The refusal arrives before the button, not after it. A download is served as the type the record claims, so a rename that could move the extension would let the record rewrite what the bytes are. - BuiltA record can be moved between folders, and the destination is governed.
This is the write the classification floor was written in anticipation of: a folder sets a floor, nothing filed inside it may be classified below it, and a move is a placement. The destination select offers only the folders that would accept the record, so a person meets the limit rather than the refusal. A folder cannot be moved inside itself or anything it contains — nothing in the schema forbids that, and the result is a subtree detached from the tree, which on screen is indistinguishable from the files having been destroyed.
The file is Public, and Design Library is the only Public folder. The four Internal folders are not refused on submit — they are simply not offered, so a person meets the limit rather than the refusal. - BuiltA selection can be moved, deleted or downloaded as one archive.
Tick rows and a bar appears. The move and the deletion are loops over the single-record endpoints rather than a bulk verb, so each record answers for itself, leaves its own row in the trail, and a partial outcome names the records that did not go instead of reporting a count. Deleting asks first, stays administrator-only, and deliberately offers no retention override: one written reason covering ten records is an exception nobody wrote about the record it is filed against.
Download is off because the demo carries a file’s name and size and never its bytes — disabled with the reason in a tooltip rather than failing silently. Delete is absent entirely for anybody but an administrator. - BuiltThe archive carries the classification on the outside as well as the inside.
Every entry keeps its “RESTRICTED - name.dwg” prefix, and the ZIP itself is named with the highest classification it holds — understating what is leaving is the one direction that must not be wrong. It is bounded at 50 files and 200 MB, measured from the records before a byte is fetched, and it refuses whole rather than in part: an archive quietly missing one of twenty files is byte-for-byte the same kind of file as a complete one. Each record leaves its own download row in the trail.
- BuiltA project can be granted to a whole department.
Access was granted one person at a time, which works and does not scale: forty engineers and a dozen programmes is four hundred and eighty decisions, re-taken every time somebody joins — and it fails silently, because a new joiner with no grants sees an empty tree and reports the product as broken rather than as locked. A grant now names a person or a department, and what somebody can see is the union of the two. The card states how many people a department grant reaches today and that it follows whoever joins afterwards.
The head count is live rather than stored, because the whole point of the grant is that the set moves. The withdraw button says what one click does: it holds the only grant, so taking it away puts the project out of every ordinary caller’s reach. - TightenedA department is now an access right, so it is no longer something you can set about yourself.
It was editable on your own profile, which was right while it reached nothing but a profile card. The moment a project can be granted to a department, a self-set department is a way to grant yourself everything that department holds — no refusal anywhere, and every screen correct while it happens. It moved to the directory beside the role, at administrator only, and the change is recorded at warning severity because it can hand somebody a programme without any row appearing against the project. The Profile tab says why the field is read-only rather than leaving it greyed out.
- FixedTwo build checks were wrong rather than the code they guard.
One forbade any column called “department” from referencing the department register, which was a loose stand-in for a rule about one table and would have forced the wrong answer on the new one. The other searched raw source for queries against the user table, so a comment saying “this deliberately does not query it” failed the build. A guard nobody trusts gets switched off, so both were narrowed to what they actually mean.
- FixedA paragraph in the operator guide had described the opposite of what the product does.
It said a project nobody has been granted is open to everyone, which stopped being true when that rule was reversed three migrations ago. It is the sentence an operator would read before deciding whether withdrawing the last grant was safe — and it told them it widened access, when it strands the project.
A public record of what has been built
This page. Every change so far, written for somebody who has never opened the code, served without a sign-in, and held against the repository by the build.
- BuiltThere is a changelog, and anybody can read it without an account.
It is the only page in the product served with no session, because its audience is not a user of the system — it is a colleague, a manager or a client being told the thing is moving, and a sign-in in front of a list of work is a door in front of the only question it answers. What makes that safe is that it renders one static table and reads no record.
No sidebar and no account menu — none of it would mean anything to a signed-out reader. What it has instead is a name, a way in, and a contents rail down the left. - BuiltEvery entry says what it means in plain terms, then how it actually works.
The audience is genuinely both a manager and an engineer, and a page that picks one loses the other — so neither half is optional. Four words carry the rest: built, tightened, fixed, groundwork.
- Built“Tightened” is its own word, because most of the security work was neither new nor broken.
A switch that existed, stored its value and governed nothing, given something to check — the release password, the domain lock, the personal-data flag, the release policy. Filing that under “built” reads as a feature nobody asked for; under “fixed”, as something broken in front of a client. It was neither.
- FixedThe sections were regrouped, because three of them were doing two jobs each.
Eleven became twenty-one. One heading had covered both the authentication work and the move onto a database; another covered disposal, five unrelated controls and the directory. A section is one theme now — if the heading needs an “and”, it is two — and the build fails if one grows past twenty entries.
- BuiltThe screenshots are of the change, not of the screen it happens to be on.
Mostly element-level crops taken from the running product — the four release-policy rules rather than the page they sit on, the pager sentence rather than the register. They open in a dialog that closes on Escape, on the backdrop or on a control the reader can see, rather than navigating away to a bare image file.
- FixedA screenshot opens at the size it was captured, rather than barely larger than it already was.
The dialog had been fitted to the picture, which made the small crops — the ones most worth enlarging — open almost unchanged. It fills the viewport now, and the files are kept at twice the size they are drawn at so enlarging one costs nothing in sharpness.
- GroundworkA deploy waits for the container to answer before calling the health check failed.
It probed once, the moment the containers came up, so a deployment that had worked reported a 502 from an application still starting. A check that is sometimes wrong about failure stops being read — the same reason the pre-push hook is kept to seven seconds — so it now retries for a minute, and a real failure still fails.
- FixedTen screenshots were blank, cropped around the wrong thing, or captioned for a picture they were not.
Two causes and a consequence. Headless Chrome paints only what is on screen, so any crop running past the viewport came back white — six did. A sticky header is painted at its *viewport* position, so in a full-page capture the top bar landed halfway down three more. And four captions described what the crop was aimed at rather than what it caught, which is the same defect as a stale document, one layer out.
- FixedThe picture of this page is no longer a blank rectangle.
Two causes, and the second is worth knowing: the capture itself was empty because headless Chrome only rasterises what is in the viewport, and replacing the file changed nothing on screen because Next caches optimised images by source path — so the old bytes kept being served from a page whose file on disk was perfectly correct.
- BuiltThe page shows no dates and the file does, which is one decision rather than an omission.
The work arrives in bursts — several changes in an afternoon, then nothing for days — so a dated list on screen measures the calendar rather than the product. Somebody reading the repository is usually asking exactly when a thing landed, so CHANGELOG.md carries the dates.
- GroundworkThe page and the markdown file come from one table.
`pnpm changelog` writes CHANGELOG.md from src/lib/changelog.ts, and the build fails if the two disagree. Written once and rendered twice, so the copy a teammate reads on the repository and the copy a client reads in the browser cannot drift apart.
- GroundworkThe build fails when the record falls behind the repository.
It compares the newest milestone against the date of the last commit, so shipping something and leaving this page alone is caught rather than remembered. It is day-granular, and it fails open where there is no git to ask — “cannot tell” is not “nothing pending”.
Keys, retention, and people who have left
The last four controls that were honest about being incomplete — encryption keys, a retention period nothing read, an extract an outsider can trust, and the terms a recipient actually agreed to.
- BuiltThe terms a recipient accepts before downloading are a governed document.
The undertaking is versioned and recorded, so the trail says which text somebody agreed to rather than that they ticked something. Acceptance is asked per file and per address, so the second file of a pair is not released on the first one's tick.
- BuiltThe audit extract is sealed, so a file saved to a laptop can be shown to be the real one.
Exporting the trail writes a digest of the exported file back into the trail, which is append-only — so a saved copy can be re-checked against a row nobody can revise. That settles it for somebody who already trusts this deployment, which is the limit worth saying out loud, so there is a second seal for somebody who does not: where a signing key is configured, a detached Ed25519 signature travels with the file beside the public half that verifies it, and an auditor can check it offline months later with openssl and no conversation. It is asymmetric deliberately — a shared secret would only be checkable by whoever already holds it, which here is this deployment.
The claim the trail makes about itself, on the screen that makes it. - TightenedA record cannot be deleted while it is still inside its retention period.
The period was recorded on every file and nothing read it. Deletion now refuses against the database's own clock, inside the locked transaction — and an administrator can override it with a written reason, which goes on the trail, because a refusal with no way past it moves the pressure to somebody's database client.
- BuiltEvery stored file has its own encryption key, and the master key can be changed.
One key used to encrypt everything, so rotating it made every existing object unreadable. Each object now has its own data key wrapped by the master, and a retired master stays on the keyring so old files still open.
- BuiltSomebody who has left is refused at the door, rather than when IT closes their mailbox.
Sign-in is by emailed link, so a leaver kept working access for as long as their address did — a different team's timetable. A leaver is now marked as one and the next attempt is refused, with the refusal recorded.
- BuiltOffboarding has a button rather than a row somebody edits in the database.
The control sits on the settings screen beside the rest of the directory, so ending somebody's access is an act with a person's name against it.
Operating the thing once it is running
The half nobody sees until it goes wrong: shipping the right images, knowing which schema a host is on, and a storage layer telling the truth about what it holds.
- FixedA deployment running behind its own database schema says so.
The health probe reported "migrated" as long as any migration had ever been applied — equally true of a database stopped at the first one. It compares the full set the running image carries against the set the database holds.
- FixedStored files report their real size again.
Encryption adds a few bytes and the storage layer was reporting the encrypted length where the record held the original, so every encrypted download refused. The cause was a metadata key read back in the wrong letter case, which only shows against a real bucket.
- FixedDeployment ships every image the host runs, not just the application's.
The migration runner is a second image and was not being shipped, so the host could run new application code against a schema migrated by an old one.
- FixedRollback stops offering a half-deployed image as a target.
An image that failed part-way through a deploy was still listed as somewhere to roll back to, which is the one moment that choice is made under pressure.
- FixedThe deploy expands paths on the host it is deploying to, not on the laptop running it.
A remote directory was being resolved locally, so the path that reached the server was whatever happened to exist on the machine typing the command.
- FixedThe dev server's port matches the sign-in links it sends.
Setting the port in configuration moved the links and not the server, so every link went to a port nothing was listening on. Three smaller interface fixes shipped alongside it.
- FixedA file uploaded in the demo shows the size it declared, not zero.
The demo has no real bytes, so the upload action was fabricating an empty stand-in file and the declared size arrived as 0 B on screen.
Reading a register that has grown
Lists that say how much they are showing, rows that answer for themselves, and a trail that stopped printing its own internal tokens at the reader.
- BuiltEvery long list is paged, and says what it is showing.
A register showing twenty-four releases is indistinguishable from one showing twenty-four of three hundred. The audit trail is the exception and says so — it is read by cursor, so it offers "load older" rather than numbering a subset as though it were the record.
The count sentence is the control's reason, not its trim: twenty-five rows with nothing saying how many there are is indistinguishable from a register that holds twenty-five. - BuiltList rows and filter tabs answer for themselves.
A row now carries its own status, classification and counts rather than relying on a column heading three screens of scrolling away.
- FixedThe register's tab counts read their own state.
The count beside each filter tab was being computed from the wrong list, so it disagreed with the rows underneath it.
- BuiltA transfer's file list is capped, with the rest behind a dialog.
A release carrying forty files was turning its own record into a scroll, burying the approval and the policy underneath it.
- FixedThe audit trail stopped printing its own internal tokens at the reader.
Five fields were being pasted raw into the record, so the trail carried sentences like "Verification rejected: outside_registered_domain". The token is still what a query filters by; the sentence is now composed where the record is written.
It works on a phone
Every screen given a narrow layout, with the rule that a table keeps its columns and scrolls rather than quietly dropping them.
- BuiltEvery screen works on a phone.
Layouts, spacing and controls reworked across the product rather than a page at a time, so the whole thing is usable on the device somebody actually has with them at a supplier's factory.
The dashboard at 390px. The figure tiles go two-up rather than shrinking, and the sidebar becomes a sheet. - BuiltTables keep all their columns on a narrow screen and scroll instead of dropping them.
Hiding a column on a phone quietly changes what the register says. The scroll is made visible so a reader knows there is more to the right.
- FixedThe role matrix and the figure tiles stay whole on a phone.
Both were being squeezed to the point where the numbers and the ticks stopped lining up with their labels.
- FixedGrids stop sizing themselves to their widest cell.
A long vendor name was stretching the whole layout sideways and pushing the rest of the page off screen.
- FixedA scrolling strip stops drawing a scrollbar across the controls, and the walkthrough fits a phone.
Any single row of controls that runs off the side now uses one shared wrapper, because the obvious CSS also lets the row scroll vertically by the height of a focus ring.
- FixedA definition is reachable by tapping, not only by hovering.
The vocabulary tooltips were invisible on a touch screen, which is the device the person least familiar with the words is most likely to be holding.
- FixedVertical separators sit centred, and dead styling was removed from the record cards.
Two corrections found while the phone layouts were being done — the separators were already being centred by the row around them, and the record cards were carrying spacing classes that no longer did anything.
The product explains itself
A guide that works out what you have already done, a walkthrough that plays the real screens, definitions where the words are used, and empty states that say which kind of empty they are.
- BuiltA getting-started page that works out what you have already done.
It names the job each role holds and lists the first four or five things to do. Progress is derived from your own records rather than stored, so it stays right when a project grant is withdrawn — and four steps deliberately cannot be ticked, because nothing says whether a person has read something.
Each tick is derived from the reader's own records rather than stored, and the steps with a dashed marker are the four that nothing can score. - BuiltA two-minute walkthrough that plays the product's own screens.
Built from the same components the real screens use, so it cannot go stale the way captured images do. It zooms to whatever it is pointing at and measures the target rather than hard-coding a position.
Built from the same components the real screens use, so it cannot drift from them the way a captured image would. - BuiltThe walkthrough runs in chapters, and says plainly what each step is.
One pass for somebody following it end to end, five independent chapters for somebody who came back for one. No chapter is ever locked.
- BuiltThe guide shows its steps happening rather than only listing them.
Each step in the checklist is illustrated by the part of the walkthrough that demonstrates it, and the build fails if a step has no illustration.
- BuiltThe product explains its own vocabulary where the words are used.
A ceiling and a floor run in opposite directions and read almost identically; a release is a governed act rather than a file. Each is a quiet underline that gives the definition in place — including where the honest half is what the product does not do.
- BuiltEvery empty screen says which kind of empty it is, and offers a way out.
"No files" had been standing in for four facts: still loading, the fetch failed, the deployment holds nothing, and you have not been granted any project. The last is the commonest.
- FixedThe file tree says it is narrowed whenever it is, not only when it is empty.
A reader with two projects out of nine was being shown a short list with nothing to say it was short.
Each role gets its own product
A control a role can never use is absent rather than disabled — which meant deciding, screen by screen, what each role is actually for.
- BuiltEach role is shown only the screens it can actually use.
The approvals inbox used to render its whole queue to a sender with every button disabled, under a banner apologising for it. A control a role can never use is now absent rather than disabled.
The matrix the navigation is built from. A row a role cannot act on is not rendered as a disabled control any more — the screen is simply not offered. - BuiltThe sidebar's shape fits how short a role's menu is.
Three group headings over runs of two, two and one is more chrome than list, so a short menu becomes one labelled block instead.
Five destinations under one heading. The three group headings appear from six, because three labels over runs of two, two and one is more chrome than list. - BuiltThe account menu moved into the sidebar footer.
Identity and the two things you can do about it were on opposite corners of the screen, with Settings reachable from both.
- BuiltA sender can read the trail of what they did.
The audit log had been withheld from senders entirely — right about the whole register, which names every person and address on the deployment, and wrong about the rows recording your own acts. Same screen, same filters, same sealed extract.
The same screen, the same filters and the same sealed extract — over the rows naming the reader as the actor rather than the whole register. - GroundworkThe ISO mapping and the role matrix were made answerable rather than exhaustive.
Both were long enough that nobody read them. They are now shaped around the question somebody arrives with instead of listing everything at once.
Telling the demo apart from a real deployment
The product ships as both, and every screen that quietly rendered seeded data to somebody with a real database was a lie about their own records.
- BuiltThe demo runs as its own container beside a live deployment.
The switch that puts sign-in links on screen also overrides mail delivery, so on a deployment holding real data it would hand anyone an administrator session while every screen still looked correct. It is a property of the process, so the demo is a second container with no database behind it.
- BuiltThe demo controls are separated from the product's own chrome.
Switching persona and resetting the seed are demo affordances; they sat among controls that do real things, which is how a demo gets shown as though it were the product.
Everything that is a property of the demo rather than of the product lives behind one labelled control that says so. - FixedThe dashboard stops greeting a real deployment by the demo seed's first persona.
Until the session answers, the store reports a placeholder identity — three screens were rendering it rather than waiting.
- FixedTwo more screens stop naming a seeded persona on a real deployment.
The same placeholder, found in two further places once it was known to be a pattern.
- FixedA real deployment is no longer offered a button to reset demo data it does not render.
The control did nothing on a deployment with a database behind it, which is worse than not offering it.
- FixedThe upload page stops sending a demo folder id at a real database.
The destination folder was coming from the seed rather than from the tree the screen was showing.
- FixedDeadlines are measured against a real clock on a real deployment.
The demo's clock is frozen so the seeded data stays in its intended state; a record written by a real server was being measured against it, which put dates out by months.
- FixedThe recipient portal stops telling a vendor their blocked attempt was recorded.
It was not, and a screen claiming an audit entry that does not exist is worse than one that says nothing.
- FixedThe settings screen stops contradicting itself about project scope.
One card described the old rule, where a project with no members was open to everybody, and another described the new one.
Switches that had been governing nothing
Five controls the product offered, stored and drew a chip for, with nothing anywhere checking them. This is what the word “tightened” is for.
- Tightened"Lock to vendor domain" has domains to lock to.
The switch had shipped from the first migration — offered by the wizard, stored in the column, drawn as a chip — and no registered domain existed anywhere to compare against. Vendors now carry a domain set, administered deliberately rather than derived from whoever was last added as a contact.
- TightenedThe release password switch has a password behind it.
The toggle had existed from the first migration with no field to type one into, nothing stored and nothing asking on the recipient's side. It is now collected, hashed and checked at the server after the one-time code.
- TightenedA folder's classification floor is enforced at the server, not only in the dialog.
Without it a restricted project is declassified by creating a public subfolder inside it and filing the drawings there — no override, no exception on the register, nothing that looks wrong.
The screen offers only what the destination folder permits, so a person meets the limit rather than the refusal. - TightenedA release carrying personal data is refused unless watermarked and identity-checked.
Files have carried a personal-data flag since the first migration and nothing read it. The rule bites hardest below Confidential, which is the point — the case worth catching is the internal release nobody thought twice about.
- FixedA file returned by a vendor keeps its personal-data flag.
Everything else was correctly inherited from the release and this one field was hard-coded to false, so a marked-up file full of names re-entered the register unflagged and the next release of it passed the one rule that would have refused it.
- BuiltA file's version history has a way to add to it.
The label is minted by the server, and a revision cannot change the record's classification, project or retention — a caller who could declare those could relabel a restricted drawing by revising it.
The label is minted by the server. A revision cannot change the record's classification, project or retention. - BuiltThe classification travels out with the copy, in the download's filename.
Served as "RESTRICTED - stamping-die-layout-d55.dwg", because a label that exists only inside the product stops existing the moment somebody presses Download. Public files are labelled too.
The destruction obligation, and the certificate that closes it
What happens after access ends: the obligation opened on a clock, the notice carried to the supplier, and a printable attestation that says on its face what it is evidence of.
- BuiltThe certificate of destruction is a document you can print.
It renders the closed obligation as a page using the browser's own print-to-PDF — no library, and one layout to keep true rather than two descriptions of the same figures.
- TightenedThe certificate reference comes from the vendor's own document.
It had been derived — a number this deployment invented, filed as evidence that somebody else had issued a certificate. It is typed in from the document the vendor sent, and the field is deliberately not prefilled.
- BuiltThe destruction obligation opens on a clock rather than on somebody noticing.
When a release's access window closes the obligation is opened with its deadline. Sending the notice is deliberately still a person's act, because an email cannot be withdrawn.
The queue an administrator works from. The obligation is opened automatically; sending the notice and recording the certificate stay a person's acts. - BuiltThe destruction request is actually carried, and recorded when the carrying fails.
The obligation is recorded before the message is attempted, and the despatch is stamped only on a real send — an unconditional stamp would read "notice sent" with no supplier ever told.
- FixedThe disposal queue sheds columns on the card's width, not the window's.
It was dropping columns on a wide screen because the card it sits in is narrow, and keeping them on a phone.
Names people can say, periods they can choose, and a directory they can edit
The vocabulary of the product as an operator meets it: a transfer reference instead of a key, a reporting window, and the registers behind who somebody is.
- FixedA transfer has a name a person can say, instead of the key it is stored under.
A real deployment was naming releases things like t-8f9b46b1-2ce0-4cdd-bfb8 on every screen where somebody would say "TRF-2026-0117". The reference is written by the database and is what every screen renders.
TRF-2026-0117, not t-8f9b46b1-2ce0-4cdd-bfb8. The internal key is still what the audit trail cites, because that is the join. - BuiltThe reports and the audit log take a period.
A preset sets the start of the window and never the end, because stamping "now" as a boundary drops the rows written between computing the window and running the query.
The period control sits at the top right. A preset sets the start of the window and never the end. - BuiltEach figure says whether the period applies to it.
Releases raised takes the window; files under management, expiring links and agreements due for renewal are statements about now and are labelled as current. One figure comes from a count that takes no parameters at all, and the tile says so.
Read the small line under each number: “all time” is a flow the window applies to, “as at today” is a statement about now that it does not. - BuiltThe audit trail can be exported, sealed against the log itself.
A CSV whose digest is written back into the append-only trail. Cells beginning with an equals sign are neutralised, because an evidence file is opened in a spreadsheet by definition.
The extract's digest is written back into the append-only trail, so a saved copy can be checked against the record it came from. - BuiltPeople can correct their own name and department.
Only their own, and only those two fields — the email is the sign-in address and the role is not theirs to change. The trap was the sign-in path, which used to overwrite a typed name at the person's next sign-in.
Two fields are editable and two are not: the email is the sign-in address and the key, and the role is granted by an administrator rather than self-declared. - BuiltDepartment is an administered register rather than free text.
Retire and restore, deliberately no rename or delete: the name is what is stored on each person, so a rename rewrites what a profile said at the time and a delete strands those rows.
- FixedA new client no longer inherits a fictional company's departments.
The demo personas' departments were being seeded by a migration, so the first administrator opened the register and found somebody else's org chart in a list that offers no way to empty it.
- BuiltPeople stop having to know a project code by heart.
The code is picked from what is already in use rather than typed from memory, on all three screens that take one.
The code is picked from what is already in use rather than typed from memory — and it is required, because a record filed without one would sit outside the project rules entirely. - BuiltAn administrator is offered the projects they have granted but nobody has used yet.
A project that has been granted and not yet written to holds no files and no releases, so it was invisible in the one picker the person who created it would reach for.
- FixedRoles and control statuses are written out rather than shown as their raw values.
Screens were rendering the internal enum — "not-demonstrated", "approver" — where a person needs a sentence.
- GroundworkThe dashboard states each fact once, and reddens only what was refused.
Three cards were reporting the same number in different words, and ordinary activity was being coloured as though it had been blocked.
- FixedThe throwaway-database script uses a password the compose stack has actually used.
It was generating a fresh test database with credentials nobody could then connect with, so the integration suite failed on the database rather than on the code being tested.
The checks that keep it honest
Every claimed control has to point at a test that proves the refusal, the running container can say which commit it is, and four documented claims that had quietly become untrue were corrected.
- GroundworkEvery control the product claims has to name the tests that prove it.
A control could satisfy the mapping, have a specification row and a verbatim title, render green, and have nothing asserting the refusal it claimed. The build now fails when one does — and a control with no honest home goes on an exemption list with its reason written out.
- GroundworkA release is tested end to end, not one method at a time.
Every method passed its own test, which left the gaps between them uncovered. Four narratives now run the whole chain: raise, decide, deliver, close out.
- GroundworkThe integration suite gets a throwaway database.
A shared one quietly changes what tests mean: one test was asserting a restriction it had inherited from the demo seed rather than established itself, so it passed locally and would have failed on a clean database.
- GroundworkThe fast half of the check gate runs before a push leaves the machine.
Lint, unit tests and the documentation check — about seven seconds. The slow ones are left to CI deliberately, because a hook people wait for is one they start skipping.
- GroundworkThe client-side data layer is covered as far as the test runner reaches.
Added because a real bug used the gap: one branch dropped a field, the whole suite stayed green, and only the browser showed it. The boundary of what the suite does not cover is stated rather than implied.
- BuiltThe running image says which commit it came from, and the sign-in page shows it.
There was no way to tell which build a host was running, so "is my fix deployed" is now answerable by looking.
- FixedThe About tab stops telling operators there is no backend.
True when it was written and false three times over on the hosted deploy, on the one screen whose subtitle is "what is real and what is simulated".
- FixedOne control stops claiming a gate that did not exist.
The personal-data flag was recorded and read by nothing, and the mapping said otherwise. The claim was corrected first and the gate built afterwards.
- GroundworkThe build-elsewhere-and-ship deployment path was written down.
The host is small enough that building on it is not always possible; the README pointed at the path without describing it.
Who may see which rows
A role said what you could do; nothing said which records the answer could be about. Ownership went into the schema, and the file tree, the transfer register and the vendor registry each learned to narrow.
- BuiltFiles and releases belong to projects, and you see the projects you have been granted.
Ownership is now in the schema and the file tree is narrowed at the server. The unit is the project rather than the individual file, because a per-file access list would be a second register to keep in step with the first.
The tree is narrowed at the server to the projects the reader holds — and the screen says when it is narrowed, rather than presenting a short list as the whole record. - BuiltThe transfer register is narrowed too — except for approvers, deliberately.
An approver sees every release and only their own projects' files. Narrowing the register would either stop them doing the job the role exists for, or require granting them everything — and the first fails silently, because the queue would say "Queue is clear".
- BuiltProject access is granted and withdrawn inside the product.
It had been rows in the database. Withdrawing the last grant strands a project, so every layer says so — the button's own label, the confirmation, the severity on the audit row and the message afterwards.
Read the Audit trail row — a sender sees their own acts, an approver and an administrator see the whole trail. The grants themselves are administered in the card below this one. - TightenedA project nobody has been granted is administrators-only, instead of open to everybody.
An empty member list had meant "unrestricted". The reversal came with a backfill that grants each existing project to the people who had actually worked in it, so nobody lost access to their own work.
- TightenedA project code is required on every write, so nothing can be filed outside the rules.
There was a placeholder code for unassigned records, visible to everybody, and leaving a form blank was enough to get one. It is now refused by name rather than silently rewritten.
- BuiltThe vendor registry is projected by role rather than scoped by project.
A vendor has no project code and both ways of inventing one are worse than the gap. A sender reads the registry with three governance records withheld: review findings, superseded agreements, and any ceiling exception with its justification.
What a sender reads. Three governance records — review findings, superseded agreements and any ceiling exception — are withheld from this payload rather than hidden on the screen. - FixedOne record's audit trail is narrowed to the records the reader may open.
For three commits, asking for a file by id answered "not found" for an ungranted project while asking the trail for that same file returned its every download, the recipient's address and the IP addresses.
- BuiltA file record has a delete button, and the register can refuse it on screen.
Three refusals rather than one, because they differ in what a person can do about them: a released file can never be cleared, a folder that is not empty can be emptied, a release's source folder clears when that release ages off.
The dialog names the record before it asks. Postgres is the authority and is asked again under a row lock, so this is a preview of the refusal rather than the refusal itself. - GroundworkA scope test was made to establish the restriction it asserts.
It was using a project code the demo seed happens to grant, so it inherited the restriction rather than setting it up — passing against a long-lived database and failing against a fresh one.
- GroundworkThe access check stops reading a grant it is about to discard.
A query per request that was thrown away for the one role that is never narrowed.
Work that happens without anybody pressing a button
A scheduled job that withdraws access when an agreement lapses, rescanning of stored bytes, a retention watch, and anomaly findings recorded once per episode rather than once an hour.
- BuiltA lapsed vendor agreement closes the releases that are still live under it.
It runs as a scheduled container rather than an endpoint — an HTTP sweep would put "revoke this vendor's access" on the public internet behind a shared secret. Its exit codes distinguish "nothing was due" from "half of it failed".
- BuiltVendors can send files back through a controlled channel.
A recipient declares a name, the bytes and a note. Classification, project, retention and custody are all derived from the release — a caller who could declare them could hand back a restricted drawing labelled public.
- BuiltThe return channel has a screen, and three records stopped disagreeing about it.
The sender's own "returned by vendor" card, the timeline and the file tree were each being told about a return by a different path, and the demo only updated one of them.
- BuiltStored files are looked at again later, and a condemned download is refused.
A scanner that files a verdict and lets the bytes keep flowing is a detector nobody reads. The bytes are deliberately kept rather than destroyed — they are the evidence.
- BuiltThe retention period is watched, and deliberately nothing is deleted on a timer.
The job reports a record whose period has elapsed and stops there. Destroying a company's engineering data on a schedule is a decision a deployment takes deliberately, not one it inherits by upgrading.
- BuiltUnusual download patterns are read out of the trail.
The signals had been recorded since the beginning and nothing had ever looked at them. Two rules: velocity, and one release downloaded from several addresses.
- BuiltThe findings are on the dashboard, and the demo computes its own.
The demo calculates the anomaly from its seeded rows rather than describing one, so what is on screen is the control working rather than a picture of it.
- BuiltA finding is recorded once per episode, not once per hour.
The detector reports the widest window a rule held over, so keying the record on the window files the same anomaly every run into an append-only table.
- BuiltA sender's own copy has a deletion path, refused by the register where it should be.
The record goes before the bytes — the deliberate inverse of an upload, because bytes destroyed under a record that then refused to go is a retention rule losing after the fact.
- GroundworkThe README stopped promising a cron mail the sweep will not send.
And the count of what the sweep actually does was corrected, which had drifted as jobs were added.
Turning the claims into controls
Encryption at rest, malware scanning, a release policy that is actually applied, and a recipient link tied to the release it names — four things the documentation had been describing rather than doing.
- BuiltStored files are encrypted at rest.
It wraps whatever storage backend is configured rather than living inside one, so the guarantee holds on a managed bucket and a self-hosted one alike. A misconfigured key throws rather than quietly resolving to unencrypted storage.
- BuiltUploads are scanned for malware.
It runs before the record is written, so a refused file leaves no row, no version and no object — only an entry on the trail. It fails closed on every failure including an answer it cannot parse.
- BuiltThe release policy became a rule that is applied, rather than a page that saves.
It stored four rules faithfully, audited every edit, rendered its own history, and nothing read it. Every field is a ceiling and never a default: the policy can make a release stricter and can never loosen one.
Four rules, one per classification. Every field is a ceiling: a release asking for less keeps what it asked for, and one asking for more is refused by name. - TightenedA recipient's link is tied to the release it names.
The address a recipient verifies against comes from a signed invitation checked against the register, rather than from the request — otherwise the endpoint is a way to mail a one-time code to anybody on the internet.
- TightenedRevoking a release reaches the recipient sessions already open.
What it cannot reach is a cookie already issued, which is stated plainly rather than described as instant. The endpoint refuses the deployment's own token — ending somebody's access is a person's decision.
- TightenedThe handling acknowledgement is enforced where the download is.
It had been a checkbox the page remembered. It is now a gate in front of the bytes, asked per file and per address.
- FixedA view-only release is sent with its real ceiling.
It was also refusing the longest window the policy actually permits, so the wizard was rejecting a request it had just offered.
- GroundworkThe invitation gate is exercised against real rows rather than fixtures.
A fixture agrees with whatever the code does; the point of this gate is what the register says.
The screens start reading the record
Six screens were still rendering the browser's own copy on a deployment with a database behind it — including three that claimed an empty register over a read nobody had answered.
- BuiltThe compliance screen counts its evidence from the deployment, not the browser.
Each control declares which kind of zero it can have, and the build fails if that disagrees with what the server actually records — a control claiming evidence it never writes reads as "none yet" for ever.
The status is the honest half. “Partial” says which part of the control this product performs and which belongs to the wider management system. - BuiltThe audit trail is gated on its scope, which the role matrix had promised all along.
One record's history is open to every signed-in role; the whole register is not, because it names every person, address and device on the deployment.
- BuiltA file's activity tab shows the real record.
It had been reading the demo's narrative in both modes, so a file the server held showed seeded events or "nothing recorded" while the trail had its upload, its versions and every read of its bytes.
- BuiltThe dashboard reads the deployment's own record.
It was the last screen still rendering the demo seed on a real deployment.
It was the last screen still rendering the demo seed on a real deployment. - BuiltThe blocked-attempt figure is the deployment's own.
And a percentage that was measuring a required field against itself was removed — where the schema guarantees a property, the honest thing is to state the guarantee, not to compute 100%.
- FixedThree screens stop claiming an empty record over a fetch nobody answered.
"Queue is clear" is the sentence an approver acts on by walking away, so a screen must not say it while the read is still in flight or has failed.
- FixedThe reports screen stops asserting over a read that failed.
Its own banner promised it would not, which is the worst version of this bug.
- FixedA vendor page stops vouching for a vendor it has not loaded.
And three screens stopped pointing at a menu that is no longer there.
- FixedThe Docker build gets a heap it can actually use.
The build was failing on a memory limit, and the error it produced named something else entirely.
- GroundworkFour paragraphs describing shipped controls as absent were corrected.
Documentation that is wrong is worse than documentation that is missing, and no script catches a sentence that has quietly stopped being true.
A deployment a client can actually open
TLS in front, object storage on S3, sign-in mail through a second provider, and the recipient's portal reading the register rather than the browser — with a download ceiling that holds under load.
- BuiltThe product can go on a real domain with certificates.
TLS is terminated in front of the container, and the data source reaches it rather than living in the visitor's browser.
- BuiltA client's domain is one line of configuration.
The sign-in URL is derived from the site address, so pointing a new domain at the deployment is a single setting rather than four that have to agree.
- GroundworkObject storage moved to S3 and the Cloudflare-specific path was removed.
Everything reaches storage through one seam, so which bucket, gateway or provider this runs against is configuration rather than a refactor. The build fails if anything constructs a storage client outside that one file.
- BuiltSign-in mail can go through Amazon SES as well as Brevo.
The second provider cost more than expected: it has no API-key header at all, so an HTTP send has to be request-signed. That is why a provider now knows how to build its own sender and read its own credentials.
- BuiltThe recipient's portal reads the register, narrowed to what a recipient may see.
It answers a projection rather than the internal record — returning the record and rendering a subset would leak the approval note, the justification for an exception and the internal narrative in the payload.
- TightenedA release's download allowance is spent in the same statement that checks it.
A read followed by a write passes every single-threaded test and over-issues under load. Sixteen simultaneous callers are raced against it to prove the ceiling holds.
- BuiltBoth downloads are recorded — the recipient's and the workspace's.
Only the recipient's spends the allowance; advancing it because a colleague opened the file would report the vendor as having taken a copy they never received.
- BuiltThe download endpoints are reachable from the screens that describe them.
The bytes existed, the endpoints existed, and nothing on any screen actually asked for them — so the whole read path was untested by anybody using the product.
- BuiltFiles can be previewed on their own record.
Every response header comes from the record — filename, length and type — never from the object store, whose values were set from whatever the uploader's browser sent.
- FixedA release credits the person who approved it, and nobody when none has.
Two screens had been naming a seeded persona as the approver on a real deployment.
- FixedThe screens stop describing rules the product does not apply.
Several hints described refusals that no code performed, which is the exact defect the next two months of work went on removing.
- FixedThe health probe reports the storage backend it is actually using.
It was still calling it by the previous provider's name after the move to S3.
- GroundworkThe four supported backends were named, and development got its own bucket.
So a developer's experiments cannot land in whatever bucket the deployment is using.
The register moves off the browser
Five entities onto Postgres behind one opt-in switch — vendors, files, transfers, the audit trail and the bytes themselves — without a single page needing a database to render.
- BuiltThe vendor registry is served from Postgres behind an opt-in switch.
Content-only: no page needs a database to render, every screen still prerenders, and a deployment with no database behaves exactly as the demo always did.
The first entity to move off the browser. No page needs a database to render, which is what keeps the demo working with none. - BuiltThe vendor registry can be written to, administrator-only at the server.
Reads stay open to every role, because the registry is reference data the send wizard needs.
- BuiltFiles and folders have a record in Postgres, with their bytes behind it.
The record is written before the bytes deliberately: an interrupted upload leaves a record with no bytes, which is visible and fixable, rather than the reverse.
- BuiltThe real file tree renders, and real bytes upload from the browser.
The first point at which the product stopped being entirely a picture of itself.
Classification is step two and the destination is step three, deliberately: the label is a decision about the file, not a consequence of where it is going. - BuiltA recorded version's bytes are served back out, to the workspace and to a recipient.
Two endpoints and two cookies: an internal session and a vendor session, which may never substitute for one another.
- BuiltThe transfer register moved onto Postgres.
Server-side only, behind the same switch as the vendors.
- BuiltThe transfer register is held in the store behind the same seam as the vendors.
So a screen asks for transfers the same way whether they come from a database or the demo seed.
- BuiltA release is raised through the seam, and the vendor's ceiling holds at approval.
An over-ceiling release can be raised and sits on the register until an administrator records the exception — refusing earlier pushes the conversation off the record, which is the opposite of what an exception register is for.
Everything the decision needs on one card — including the warning that this vendor's agreement lapses before the release it is about to carry. - BuiltA release can be decided, revoked and closed out from its own record.
And the page stopped answering "not found" while the record was still being fetched.
Every act on a release is taken from the record it is about, so the decision and its evidence are never two screens apart. - BuiltA transfer shows the server's own chain of custody rather than an empty one.
The demo's narrative and the deployment's trail are different records, and the page picks the right one.
Each act against the release, with the control it evidences and where it came from. - BuiltThe audit trail is read from Postgres, paged and filtered.
Paged by cursor, because the table only grows and grows at the head — an offset-paged listing over it would get slower for ever.
The deployment's own register. It is paged by cursor, because the table only grows and grows at the head. - FixedA verified recipient stops being bounced off the portal on a fresh load.
The session check had not answered yet, and the page treated "do not know" as "no".
- FixedThe login page offers only the demo personas this deployment would actually admit.
It was offering addresses the domain rules would refuse, which reads as a broken sign-in rather than a configured one.
- FixedThe upload file picker renders only where there is somewhere to upload to.
In the demo there is nowhere for bytes to go, so the picker opened a file dialog and then silently discarded whatever was chosen.
- FixedA release names the person who raised and decided it, instead of "Unknown user".
The names were in the register the whole time and the screen was looking them up against the demo seed, so every real release was attributed to nobody.
- FixedThe approvals queue stops describing a rule the server does not follow.
It named a specific approver as the person who would decide, and the register has no routing rule to name one from.
The seam underneath it, and the tests that hold it
Moving five entities onto a database is only safe if one layer owns the boundary — so the write path was generalised off the first entity that needed it, and the suite that proves its SQL was written against a real Postgres.
- GroundworkThe object endpoints moved aside so the file endpoints could hold records.
Bytes and records are different things and were sharing one path, so there was nowhere to put a file's record without colliding with the endpoint that stored its contents.
- GroundworkThe write seam was generalised off the shape of a vendor.
It had been built for the first entity that needed it, and the second one did not fit.
- FixedA write stops reporting a missing reference as a missing record.
Postgres raises one error for every broken reference, so a file's uploader going missing was being reported as the file going missing.
- GroundworkThe Postgres adapter and the audit writer are tested against a real database.
Their behaviour is in their SQL rather than their TypeScript, so a unit test can only assert that the code calls itself the way it always did.
- GroundworkThe pure half of the data seam is covered, along with the audit query parser.
Every parser, every response shape, and the paging cursor — the places where a wrong answer is a plausible one.
- GroundworkTwo flaky assertions were fixed rather than retried.
One compared the database's clock against the test runner's; the other failed about six times in a hundred on a signature check.
- GroundworkThe transfer endpoints are asserted not to share one gate.
Raising, approving, overriding and disposing are four different permissions, and copying one across them all compiles and audits fine.
- GroundworkCI moved off a deprecated runtime, and the four documents were reconciled.
Including the ISO mapping, which described a product with no authentication for as long as there had been none.
Sign-in, and who you are
No passwords to store, lose or leak: a single-use link to a registered address, a one-time code for recipients, and a role that lives in the database rather than in configuration.
- BuiltSigning in is a single-use link sent to a registered address.
No passwords to store, reset or leak. The link is single-use and expiring, and the refusals are recorded as well as the successes.
- BuiltSign-in links are sent by email, and what happens to them is recorded server-side.
An address outside the permitted domains, a link already used, a link that has expired — each is a different recorded refusal.
- BuiltThe recipient portal is behind a real one-time code.
It had been a fixed six-digit code compared in the page. The code is issued to the address on the invitation, checked at the server, and limited in attempts.
A recipient's door. It refuses by explaining, because the person reading it has no account here and no other way to work out what went wrong. - BuiltRevoking a transfer ends the recipient's sessions.
The dialog had always promised it. What a revocation cannot reach is a cookie already issued, which is stated plainly rather than described as instant.
- BuiltThe file endpoints are authorised by session and role.
Every data endpoint goes through one authorisation call, and the build fails if one stops making it. The deployment token is layered under it and is still refused for anything role-restricted.
- BuiltA person's role lives in the database.
Configuration seeds the role of an address that has no record yet — that is how the first administrator exists at all — and is ignored for one that does.
- BuiltA role can be changed from inside the product rather than in a database client.
Two rules live in that change and both are about ordering: the domain check is asked before the table, and the last administrator who can still sign in cannot be demoted.
- BuiltSign-in mail goes through Brevo.
The first real delivery provider, behind a seam that a second one could be added to later.
- FixedAuthentication and mail configuration reach the containers.
It was set in the environment and not forwarded, so the containerised deployment could not send anything.
- GroundworkThe authentication code got a test suite, and the gate got a new check.
The bugs worth catching here all return a plausible answer: a code that is subtly non-uniform, a link that redeems twice, a rate-limit window that resets early.
The prototype, and the seams underneath it
The whole product as a clickable demo — classification, the send wizard, the audit timeline, vendors and disposal — then storage, a database and a build gate put underneath it without changing what it does.
- BuiltThe product, end to end, as something you can click through.
Classification on upload, a send wizard that assembles a governed release, the audit timeline, and the recipient's portal. Every flow demonstrable from a single static deployment — which is still true today.
The send wizard, where a governed release is assembled — files, recipient, policy, then a recorded decision. - BuiltThe vendor lifecycle, folders, the disposal queue and tier-driven ceilings.
A vendor's risk tier sets the most sensitive classification that supplier may receive, and a deviation from the tier default is recorded with its justification rather than typed as a number.
A tier is not a label. It sets the most sensitive classification that supplier may ever receive, and a deviation from it is recorded with its justification. - BuiltUploads are backed by object storage behind a swappable seam.
Added as production architecture rather than as a dependency: with no bucket configured, every screen behaves exactly as it did before.
- BuiltPostgres is provisioned in Docker behind a swappable database seam.
Same rule — with no database configured the product is unchanged, which is what keeps it demonstrable on a laptop and deployable to a client's own infrastructure.
- GroundworkThe whole product was containerised.
One image, a multi-stage build, so what runs on a host is what was tested rather than what happened to be installed there.
- GroundworkThe build fails when the documentation and the code disagree.
The specification had drifted across four commits: a renamed route, three components that never existed, a deployment target that had moved. Every one of those was mechanically detectable.
- GroundworkA design system document was written, and the specification reconciled with the code.
Tokens, colour pairings, the two shells and the responsive rules, so a new screen has something to be consistent with.
- GroundworkThe check gate was enforced, error boundaries added, and the file API closed.
Five checks that all have to pass clean, and a public deployment that does not leave an upload endpoint open.
- GroundworkDeployment to Cloudflare Workers was added.
Later removed in favour of a container and S3 — recorded because it was real work, and because the reason it was reversed is worth knowing.
- FixedPrerendered pages are served from cache instead of re-rendering per request.
The demo's whole claim is that it needs no server to render, and it was doing the work anyway.
- FixedTransfer events are attributed to the files they carried.
The timeline recorded that something happened without saying to what.
- FixedThe audit filter row wraps instead of squeezing four controls onto one line.
The first of many layout corrections to the screen that turned out to matter most.