NAI OS
Fourteen findings on coding, code management, and deployment — from actually building and shipping one real app with OpenGSD, not from a demo.
nai-analysis is a Next.js + Supabase app with Row Level Security,
checked-in database migrations, Google OAuth, and a live Vercel deployment, built phase by phase
with OpenGSD's discuss→plan→execute→verify workflow. It also has real stakes: the
content it holds is client-confidential, and its own CLAUDE.md says so in as many
words — "security of access is not a feature here, it's the constraint everything else is
built inside of." The findings below are scoped deliberately narrow: nothing about agent design in
the abstract, only what changed once GSD had to build, manage, and ship this codebase.
The same codebase runs both speeds, and the split is clean once you look for it: access control
went big on purpose — Postgres Row Level Security plus SECURITY DEFINER RPCs as
the sole enforcement point, never an app-layer check, because the failure mode of getting
it wrong is a client-confidentiality breach, not a bug report. UI work goes quick by design in the
same repo — a GSD "quick task" pattern (quick-260727-ao0,
quick-260727-bdg) shipped a site-header logo and a copyright line same-session, no
phase, no plan file. The tell for which speed a piece of work needs isn't its size, it's what a
wrong version of it costs: a wrong RLS policy is a live security incident; a wrong header height is
a follow-up commit.
nai-analysis/.claude/CLAUDE.md takes this decision out of the moment entirely:
"Before using Edit, Write, or other file-changing tools, start work through a GSD command…
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it."
That's CIO-mode-by-policy, structurally forced rather than left to how attentive a given session
feels. In practice the two speeds from finding 1 get different postures anyway: the RLS
migrations went through full phase gates with an explicit human verify step at each one; the
Radix pointer-events fix and the card click-through fix (both same-day UI bugs) got fixed, checked,
and committed inline, closer to one-liner. The dial that actually matters is per-change
reversibility, not a fixed session-wide setting.
This project's own .planning/ directory is the proof this matters:
PROJECT.md, REQUIREMENTS.md, ROADMAP.md, STATE.md,
and a phases/ directory with a SUMMARY.md per completed phase (phase
02-01's Google-auth-and-owner-RLS work, for instance, ended with its own SUMMARY.md
and a STATE.md update). None of that depends on the chat that produced it still being
in context. A phase started on one day and resumed days later — exactly what happened moving
from Phase 1's RLS migrations to Phase 3's role-model RPC work — picks back up from those
files, not from scrollback. If a phase's state only exists in the conversation that built it, it
doesn't survive the first context reset.
This app's build was inherently local-bound work, and knowing why matters more than the general
rule: verifying a Google OAuth round-trip, a live Supabase session cookie refresh in
proxy.ts, or RLS isolation between two real accounts all need a running local dev
server, a real browser, and the project's own .env.local — none of which a
stateless cloud run has, and none of which is worth reconstructing for a single check. Where
dispatch earned its keep instead was research and drafting that didn't touch the running app at
all — ecosystem research for the original 4-phase roadmap, or generating the doc suite
(README.md, ARCHITECTURE.md, and friends) from the finished code. Pick
local when the task needs to prove something against a live, authenticated system; dispatch when
it doesn't need the system running at all.
The clearest example here isn't even a guardrail gap, it's a migration: 20260726190000_
revert_creation_restriction_add_admin_visibility.sql exists specifically to walk back an
owner-only issue-creation restriction that an earlier migration had shipped, once a later iteration
found it was too restrictive in practice. The workspace-level guardrail gap is the same shape at a
different layer: the shared repo's Stop hook had DENY patterns written before
nai-analysis/ existed as a subdirectory, so its .env.local and
Supabase keys weren't automatically covered by a guard scoped to the old tree until a later pass
added it. Both are the same finding: a rule written against the codebase's shape at time T doesn't
know about anything added after T, and someone has to notice.
Eight real migrations shipped in three days, each one gated by its own test pass rather than one final check at the end: Phase 1 shipped 3 migrations with 8/8 tests verified before Phase 2 was allowed to start; Phase 3's role-model RPC and status-filter work added 16/16 more; RLS isolation between two real accounts got re-verified after the admin-visibility revert, not assumed to still hold just because it held before the schema changed. The pattern that makes this affordable instead of exhausting is scope: each round tests the migration that round shipped, not the whole schema from scratch, so the loop stays cheap enough to actually run every time instead of being skipped under deadline pressure.
The access-control core was knowable up front and planned that way — 17 of 17 v1 requirements mapped into a 4-phase roadmap before Phase 1's first migration. The UI bugs weren't, and no amount of upfront planning would have surfaced them: a Radix pointer-events race that silently blocked navigation, and an issue card whose clickable area was too small, both only became visible once there was a real UI to click through on a real screen. The finding isn't that planning failed — it's that "known in advance" and "found by using the thing" are different discovery modes for different categories of defect, and a workflow that only budgets time for the first one will ship the second straight to users.
Every feature commit for this app — migrations, RLS policies, UI fixes, the admin-visibility
revert included — lands straight on main, auto-committed turn by turn, with no
feature branch in between. That's a deliberate bet, not an oversight: it works because every
migration is forward-only and reviewed before it's written (never edited after landing), and
because the revert migration shows the actual safety net is "ship a fix-forward migration," not
"never let a bad one reach main." The bet only holds because Vercel's preview
deployments exist as the de facto staging step; a schema change with no reviewable preview and no
easy forward-fix would need the branch that this workflow currently skips.
Documentation here rides along with the commit that needs it, not a release cut: the "quick
task" pattern pairs a feat(quick-…) commit with its own
docs(quick-…) commit in the same breath, and a stale claim gets fixed the same
way — docs(fix): correct shadcn dependency claim in DEVELOPMENT.md is a real
commit in this repo's history, catching a doc that had drifted from what the code actually does.
The generated suite (README.md, ARCHITECTURE.md, DEVELOPMENT.md,
each tagged <!-- generated-by: gsd-doc-writer -->) can be regenerated against the
live code whenever it's suspected of drifting; nothing here waits for a release milestone to find
out it was wrong.
nai-analysis is a git-tracked subdirectory of a larger workspace repo, not a
submodule and not its own repo — 156 files with its own package.json,
.planning/, and .claude/CLAUDE.md, sharing one clone and one Stop hook
with everything else in the parent workspace. The cost of that arrangement is concrete and already
named in this app's own tooling: a root-level .vercelignore exists specifically to
scope any root-directory Vercel build down to "each project's own directory (docs/ or
nai-analysis/)" — a backstop that would not be needed at all if this app
lived in its own repo with its own root. Sharing a root buys one clone and one set of conventions;
it charges rent in the form of exactly this kind of scoping guard, forever.
The .vercelignore backstop above exists because the thing it's guarding against
— which Vercel project has Root Directory set to what, whether it's git-connected at all
— lives exclusively in a dashboard, invisible to the repo and to anything reading it: "there
is no API for this toggle, so it can't be done from Claude, it's a manual click." The same blind
spot exists on the Supabase side for this app specifically: every RLS policy and RPC here is a
checked-in migration by design, precisely so the schema's security posture is not only
readable from a dashboard — but that guarantee only holds as long as nobody edits a policy by
hand in the SQL editor instead of writing a migration for it. The discipline has to be enforced by
habit, because nothing in the repo can detect a dashboard-only change after the fact.
This app's own .gitignore states its reasoning inline, written before the risk even
existed: "Safety net ahead of the Phase 2 Next.js scaffold … the app's real Supabase keys
land in .env.local — make sure no env file with values can ever be committed,
even before the scaffold writes its own .gitignore." That's the guard done right:
proactive, in place before the secret-bearing file existed. The remaining surface it can't cover is
conversational — an agent reading .env.local to debug an auth failure, or
quoting a Supabase error response containing a key back into chat, leaks the same secret somewhere
a .gitignore can't reach. The habit has to extend past what gets committed to what
gets said out loud in the session.
The RLS-as-sole-enforcement-point design is itself a bet on looking, not trusting: "an app-layer
bug leaks data; a database-layer (RLS) bug still requires bypassing Postgres itself," as this
project's own stack notes put it — a deliberate choice to put the security boundary somewhere
it has to be read and reasoned about as SQL, not somewhere a fast, unexamined app-layer change could
quietly weaken it. The GSD-workflow-enforcement rule in this app's CLAUDE.md is the same
bet at the process level: paying the plan-and-verify tax on every change rather than trusting a
quick direct edit. Not paying that tax is exactly how a wrong RLS policy or a silently-broken
invariant would survive unnoticed until it's exploited, not just until it's inconvenient.
Every finding above comes back to one line already sitting in this app's own
CLAUDE.md: "security of access is not a feature here, it's the constraint everything
else is built inside of." GSD can propose a migration, draft an RLS policy, or scaffold a Next.js
route group — but only the person who has to answer for a data leak, a duplicated Vercel
project, or a client finding their confidential issue exposed knows which of those constraints is
actually non-negotiable. Coding, code management, and deployment can all be delegated a long way.
The constraint that says what "correct" even means here can't be, and this project's own written
policy is the proof it was never meant to be.