Three steps forward, one step back: a maintenance session

I sat down to add checkboxes to the demo HTML. Four hours later I'd rewritten the logging system, rewritten the security policy, and deleted more code than I wrote. Classic.


The checkbox detour

It started simple. The demo form had name, email, message - I wanted a checkbox group too. Something like "Contact methods: Email / Phone / Mail."

I added the HTML, updated the field definition, tested it. The runtime already handled it fine - collectData detected first.type === "checkbox" and returned an array of checked values. Done.

Then I overthought it. "Why is the field type text when it's clearly checkboxes?" So I added "checkboxes" as a first-class field type. New entry in FIELD_TYPES. New validation branch. New registration in the CLI.

Then I looked at what I'd actually gained: skipping maxLength and email/tel regex on array values - which were already non-issues because nobody sets maxLength on checkboxes. The whole thing boiled down to one early-return branch that existed only because I'd created a type to justify it.

I deleted the whole thing and went back to type: text. The form worked the same before and after.

Most features don't need a new abstraction. They need the existing system to not break when handed something unexpected - and if it doesn't break, leave it alone.


The config error that wouldn't stop cascading

When fse.config.js had a syntax error, the console would fill with cryptic noise. A raw SyntaxError, then [fse/form] FSE is not defined, then [fse] Mount failed. Nothing useful.

The original fix was a try/catch around document.head.appendChild(s). Works in Chrome. In Firefox, script parse errors from dynamically inserted scripts fire outside the try scope - so Firefox users got the full noisy cascade while Chrome users got a clean error. I didn't catch this because I test in Chrome.

Real fix: pre-parse the config with new Function(code) before injecting. If it throws, print the friendly message and bail.

try {
  new Function(code);
} catch (e) {
  FSE_LOG.error("fse.config.js has a syntax error", "Check for missing braces or commas.");
  loadNext(index + 1);
  return;
}
s.textContent = code;
document.head.appendChild(s);

Then the mount guard broke. When config parsing fails, window.FSE is never defined - but freeze.js still runs. structuredClone(undefined) returns undefined, so window.__fse_internal__ gets created with FSE: undefined. My guard checked if (!internal && !window.FSE) - but internal was truthy (an object), so the guard passed right through and mount failed anyway.

// Before (broken):
if (!internal && !window.FSE) { return; }

// After:
var cfg = window.FSE || (internal && internal.FSE);
if (!cfg) { return; }

Then the load order broke. fse.log.js was loading after fse.config.js, so when config parsing failed and called FSE_LOG.error() - FSE_LOG didn't exist yet. Moved it before both config files.

bug chain

Three bugs, each hidden until the previous one was fixed. That's the pattern.


The logging cleanup

The codebase had console.* calls scattered across four files with inconsistent prefixes - [fse], [fse/form], [fse/validate]. Some had fix suggestions, most didn't.

I built FSE_LOG - a central logger with error, warn, and info. Errors always show. Warnings and info are gated behind a logging flag in config (default true). Every call gets a [formseal] prefix. Errors and warnings include a companion fix line.

Also added the submission UUID to the submit log - it was already in the payload, just wasn't surfaced. Now it shows: [formseal] Submitted | id: <uuid> | ciphertext: <truncated>.

And fse set logging true|false in the CLI. A friend pointed out it was case-sensitive - fse set logging True would silently fail. Fixed.

15 scattered console calls → one module. Not exciting, but it makes the next bug easier to find.


The security policy review

I merged two security docs that said the same thing in different words. Someone with actual security experience read the result and gave detailed feedback.

I'd written things like "third-party scripts cannot mutate them after load" and "private key is never on the same machine." Statements that sound strong but aren't strictly true. A compromised browser can still hook inputs before encryption. You can't guarantee where someone deploys their private key. I'd also missed malicious host pages as a threat vector and said nothing about supply chain / JS delivery integrity.

They also called out the "72-hour response timeline" as corporate theater for a solo project. They were right.

Full list of things they caught
  • "Even without HTTPS, intercepted submissions are opaque" → implies HTTPS isn't needed
  • "Third-party scripts cannot mutate them after load" → too absolute
  • "Private key is never on the same machine" → can't guarantee deployment context
  • "Analytics tools never see plaintext" → only true if you define "analytics" narrowly
  • Missing: malicious host pages as a threat vector
  • Missing: supply chain / JS delivery integrity
  • 72-hour response timeline → not realistic for a solo project

I replaced the timeline with "Response times are not guaranteed, but security reports will be reviewed as soon as possible" and rewrote the overclaims.

The difference between a security document that sounds impressive and one that's useful is how honest it is about its limits.


What actually shipped

  • Checkboxes in the demo - reverted the special type, kept the HTML
  • Config syntax errors - new Function() pre-parse, fixed mount guard, fixed load order
  • Centralized logger - FSE_LOG, [formseal] prefix, fix lines, logging flag
  • fse set logging - case-insensitive
  • Submit log now includes UUID
  • SECURITY.md - merged and rewritten
  • Bumped to v3.9.0 / v3.9.1

Maintenance sessions feel unproductive while you're in them. But the config error that would have confused a new user now prints a fix suggestion. The security policy that overstated guarantees now doesn't. The scattered console calls are one module. None of these were bugs - they were friction.