<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>André Marinho - Posts</title><description>Notes on software I build, bugs I investigate, and decisions I would make differently.</description><link>https://andremarinho.me</link><language>en-us</language><item><title>Designing agent skills around evidence and checks</title><link>https://andremarinho.me/posts/engineering-skills-evidence-and-checks</link><guid isPermaLink="true">https://andremarinho.me/posts/engineering-skills-evidence-and-checks</guid><description>How I split engineering reviews into four agent skills, kept source authority explicit, and separated packaging tests from checks of agent behavior.</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A skill can contain correct advice and still produce an unhelpful review. The agent can apply a rule to the wrong surface, mistake a checklist for a standard, or report a passing result after inspecting only the source.&lt;/p&gt;
&lt;p&gt;I built &lt;a href=&quot;https://andremarinho.me/projects/skills&quot;&gt;Engineering Skills&lt;/a&gt; around those failure modes. The collection covers documentation, technical SEO, search-facing content and web accessibility. The design problem was deciding which evidence each task needs and when the agent has enough of it to make a finding.&lt;/p&gt;
&lt;h2 id=&quot;split-the-work-where-the-evidence-changes&quot;&gt;Split the work where the evidence changes&lt;/h2&gt;
&lt;p&gt;Technical SEO and content review share a goal, but their checks differ. A clear, well-supported article can send a &lt;code&gt;noindex&lt;/code&gt; header. A correctly indexed page can contain an unsupported claim. Fixing either problem requires inspecting a different part of the system.&lt;/p&gt;
&lt;p&gt;I separated these into &lt;code&gt;technical-seo&lt;/code&gt; and &lt;code&gt;search-content-review&lt;/code&gt;. The first follows a page from source through its response and rendered output. The second examines what the text asserts, the evidence supporting it, and the qualifications that must survive an excerpt. Neither skill claims to predict a search engine&apos;s private ranking system.&lt;/p&gt;
&lt;p&gt;Documentation and accessibility get their own workflows for the same reason. Documentation needs comparison with the implementation: an omitted value and an explicit zero can have different behavior. Accessibility needs interaction evidence: a control with the expected name can still be impossible to operate by keyboard.&lt;/p&gt;
&lt;p&gt;Each skill can be installed independently. Its main instructions and references stay inside its directory, so using the documentation skill does not depend on another skill being present.&lt;/p&gt;
&lt;h2 id=&quot;put-decisions-in-the-entry-point&quot;&gt;Put decisions in the entry point&lt;/h2&gt;
&lt;p&gt;The technical SEO skill has this structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;technical-seo/
  SKILL.md
  AGENT-DISCOVERY.md
  MEDIA.md
  SOURCES.md
  agents/openai.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SKILL.md&lt;/code&gt; establishes the review scope, the evidence to collect and the required verification. Media checks and agent discovery have their own references. A whole-site audit must read both; a scoped fix reads the affected surface. That condition is explicit in the instructions.&lt;/p&gt;
&lt;p&gt;This follows the &lt;a href=&quot;https://agentskills.io/specification&quot;&gt;Agent Skills format&lt;/a&gt;, which supports a main instruction file with additional resources loaded when needed. Splitting files alone does not decide when to read them. The entry point still has to say which task triggers which reference.&lt;/p&gt;
&lt;p&gt;For example, a social image review needs to fetch and decode the image, check its crop and verify the actual metadata. Adding another generated card is unnecessary when an existing project image already works. The &lt;a href=&quot;https://github.com/andre-lmarinho/skills/blob/289a6e5a73cede26a6002cdac7d4dd3c7d1c2f84/skills/technical-seo/MEDIA.md&quot;&gt;media reference&lt;/a&gt; carries those checks without putting them into every SEO task.&lt;/p&gt;
&lt;h2 id=&quot;record-what-a-source-can-establish&quot;&gt;Record what a source can establish&lt;/h2&gt;
&lt;p&gt;Each skill has a &lt;code&gt;SOURCES.md&lt;/code&gt; file with primary references, their status and scope, the date checked, and reasons to revisit them.&lt;/p&gt;
&lt;p&gt;A W3C Recommendation, an informative tutorial and a community checklist have different authority. A checklist can reveal something worth investigating; the applicable standard and observed behavior determine whether it is a failure. The accessibility skill therefore asks the agent to check applicability and exceptions before attaching a WCAG criterion to a finding.&lt;/p&gt;
&lt;p&gt;The distinction is concrete in its navigation checks. More than one way to locate a page can be provided by contextual links, search or a site map. The review has to inspect which paths actually reach the page. It cannot turn one example technique into a required route name. The &lt;a href=&quot;https://github.com/andre-lmarinho/skills/blob/289a6e5a73cede26a6002cdac7d4dd3c7d1c2f84/skills/web-accessibility/SOURCES.md&quot;&gt;accessibility source record&lt;/a&gt; also separates normative requirements from tutorials and draft specifications.&lt;/p&gt;
&lt;p&gt;Search guidance has a similar boundary. An optional full-context file can help a tool read related material in one fetch. Whether that is useful depends on the task, publication scope and the consumer&apos;s limits. Its presence does not establish indexing or future citations. The &lt;a href=&quot;https://github.com/andre-lmarinho/skills/blob/289a6e5a73cede26a6002cdac7d4dd3c7d1c2f84/skills/technical-seo/AGENT-DISCOVERY.md&quot;&gt;agent discovery reference&lt;/a&gt; asks for that decision and the conditions behind it.&lt;/p&gt;
&lt;p&gt;I kept the source records local to each skill so a separately installed skill retains the references needed to question its advice. When guidance changes, the instruction and affected source record need to change together.&lt;/p&gt;
&lt;h2 id=&quot;separate-three-kinds-of-validation&quot;&gt;Separate three kinds of validation&lt;/h2&gt;
&lt;p&gt;The repository has checks for its format and distribution. Review quality needs a different procedure.&lt;/p&gt;
&lt;h3 id=&quot;skill-and-manifest-validation&quot;&gt;Skill and manifest validation&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/andre-lmarinho/skills/blob/289a6e5a73cede26a6002cdac7d4dd3c7d1c2f84/scripts/check.py&quot;&gt;&lt;code&gt;scripts/check.py&lt;/code&gt;&lt;/a&gt; validates the portable plugin manifest against its schema, compares shared fields with the Claude-compatible manifest, invokes the Agent Skills reference validator for each skill, and runs Claude&apos;s strict plugin validators.&lt;/p&gt;
&lt;p&gt;These checks catch malformed metadata, incompatible manifests and names that break the expected format. They do not verify every instruction, the truth of a source summary, or an agent&apos;s decision on a real task.&lt;/p&gt;
&lt;h3 id=&quot;packaging-tests&quot;&gt;Packaging tests&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/andre-lmarinho/skills/blob/289a6e5a73cede26a6002cdac7d4dd3c7d1c2f84/scripts/test_package.py&quot;&gt;&lt;code&gt;scripts/test_package.py&lt;/code&gt;&lt;/a&gt; creates disposable copies and checks three paths:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The archive contains both manifests, the license and the skill files, with bytes matching the source. An unrelated &lt;code&gt;.env&lt;/code&gt; file stays outside the ZIP.&lt;/li&gt;
&lt;li&gt;A version change updates both manifests. Invalid versions and downgrades leave their contents unchanged.&lt;/li&gt;
&lt;li&gt;Mismatched versions and symlinks are rejected without creating an archive.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The packager selects the two manifests, &lt;code&gt;LICENSE&lt;/code&gt; and the &lt;code&gt;skills/&lt;/code&gt; tree. That keeps repository-only material outside the distribution, although files added inside &lt;code&gt;skills/&lt;/code&gt; still need review. It also writes to a temporary file beside the destination and replaces the final archive after writing it, avoiding a partially written final ZIP.&lt;/p&gt;
&lt;p&gt;The build and these tests use Python&apos;s standard library. Schema and skill validation use the dependencies declared for that separate check.&lt;/p&gt;
&lt;h3 id=&quot;behavioral-review&quot;&gt;Behavioral review&lt;/h3&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/skills/blob/289a6e5a73cede26a6002cdac7d4dd3c7d1c2f84/AGENTS.md&quot;&gt;contribution instructions&lt;/a&gt; require an independent agent to check a realistic task when a skill&apos;s decisions or dispatch description change. Evaluation artifacts stay in temporary workspaces. There is currently no committed behavioral suite to rerun across the four skills.&lt;/p&gt;
&lt;p&gt;Applying the accessibility skill to this portfolio produced a useful example of why runtime checks matter. My animated headings wrap each word in an &lt;code&gt;inline-block&lt;/code&gt; span with its own view-transition name. In Orca 46.1 with Chromium 149, line reading announced the title word by word, and a long title repeated part of its second line. A DOM inspection alone had not exposed that reading behavior.&lt;/p&gt;
&lt;p&gt;The correction gives the heading one complete accessible label and hides its visual word spans from assistive technology. It keeps their view-transition names. This abbreviated example shows the semantic boundary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { Fragment } from &quot;react&quot;;

&amp;lt;h1 aria-label={title}&amp;gt;
  {title.split(&quot; &quot;).map((word, index) =&amp;gt; (
    &amp;lt;Fragment key={`${index}-${word}`}&amp;gt;
      {index &amp;gt; 0 &amp;amp;&amp;amp; &quot; &quot;}
      &amp;lt;span
        aria-hidden=&quot;true&quot;
        className=&quot;inline-block&quot;
        style={{ viewTransitionName: `title-${id}-${index}` }}
      &amp;gt;
        {word}
      &amp;lt;/span&amp;gt;
    &amp;lt;/Fragment&amp;gt;
  ))}
&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/andremarinho.me/blob/4e15b67ac8d21e2a75f88d638cfdc0f858e18c59/src/components/transitions/MorphTitle.tsx&quot;&gt;implemented component&lt;/a&gt; supports multiple heading levels. Its &lt;a href=&quot;https://github.com/andre-lmarinho/andremarinho.me/blob/4e15b67ac8d21e2a75f88d638cfdc0f858e18c59/src/components/transitions/__tests__/MorphTitle.test.tsx&quot;&gt;regression test&lt;/a&gt; checks the heading and enclosing link&apos;s accessible names. That protects the DOM contract; the separate Orca recheck established the announcement behavior for the tested pages.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/andremarinho.me/pull/36&quot;&gt;review record&lt;/a&gt; documents speech-generation logs captured with a Speech Dispatcher dummy output module. I did not assess audible synthesis or test NVDA, JAWS or VoiceOver. This was a fix verified in one browser and screen-reader combination, without establishing WCAG conformance or a general success rate for the skill.&lt;/p&gt;
&lt;h2 id=&quot;keep-publishing-bounded&quot;&gt;Keep publishing bounded&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/skills/blob/289a6e5a73cede26a6002cdac7d4dd3c7d1c2f84/.github/workflows/release.yml&quot;&gt;release workflow&lt;/a&gt; reuses validation, synchronizes manifest versions, builds the ZIP, and publishes a GitHub release. It rejects an existing tag and checks &lt;code&gt;main&lt;/code&gt; again before pushing the release commit and tag atomically.&lt;/p&gt;
&lt;p&gt;That atomic push keeps the branch and tag update together. Creating the GitHub Release is a later API operation and can fail separately. The release guide explains how to rebuild from the existing tag and finish that step without bumping the version again.&lt;/p&gt;
&lt;p&gt;The first &lt;a href=&quot;https://github.com/andre-lmarinho/skills/releases/tag/v0.1.0&quot;&gt;release ZIP&lt;/a&gt;, published on October 15, predates the October 16 revisions described here. Directory submissions remain separate from the GitHub workflow, and a successful release says nothing about an official listing. The remaining maintenance work includes rechecking changed sources and exercising affected decisions when the instructions change.&lt;/p&gt;
</content:encoded><category>AI agents</category><category>Developer tooling</category><category>Python</category><category>GitHub Actions</category><category>Testing</category></item><item><title>A shared demo account without a reset scheduler</title><link>https://andremarinho.me/posts/shared-demo-without-reset-scheduler</link><guid isPermaLink="true">https://andremarinho.me/posts/shared-demo-without-reset-scheduler</guid><description>How Turistar lets visitors use the real product through a shared account, restores its examples on dashboard entry, and keeps reset rules in PostgreSQL.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The demo in &lt;a href=&quot;https://andremarinho.me/projects/turistar&quot;&gt;Turistar&lt;/a&gt; signs visitors into a real, shared account. They can move activities, edit a budget, and delete a trip through the same authenticated screens and operations I use for my own travel planning.&lt;/p&gt;
&lt;p&gt;The next visitor needs useful examples even if an earlier visitor deleted them. I keep a saved baseline and request a restore when the demo account enters its dashboard, provided at least an hour has passed since the previous reset. There is no background scheduler.&lt;/p&gt;
&lt;p&gt;You can &lt;a href=&quot;https://turistar.me/login&quot;&gt;open the login page and use its demo button&lt;/a&gt;. The account is shared, so changes are visible to other visitors and are temporary.&lt;/p&gt;
&lt;h2 id=&quot;one-account-uses-the-normal-product&quot;&gt;One account uses the normal product&lt;/h2&gt;
&lt;p&gt;The login and signup screens already have a demo entry point. Their sign-in helper authenticates the shared account through the application&apos;s auth flow. After login, the dashboard recognizes that account and calls &lt;code&gt;resetDemoIfStale&lt;/code&gt; before fetching its data.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/src/features/demo/README.md&quot;&gt;demo feature documentation&lt;/a&gt; describes that path. A visitor uses the existing permissions, itinerary editor, and budget operations. I don&apos;t have a second implementation of those features to keep in step with the product.&lt;/p&gt;
&lt;p&gt;The tradeoff is shared state. Two visitors can change the same itinerary or see each other&apos;s edits. Serializing the resets prevents two restore operations from competing; it does not give each visitor a private sandbox.&lt;/p&gt;
&lt;h2 id=&quot;entry-requests-a-check-postgresql-decides-whether-to-restore&quot;&gt;Entry requests a check; PostgreSQL decides whether to restore&lt;/h2&gt;
&lt;p&gt;The server helper makes one repository call, which invokes &lt;code&gt;maybe_reset_demo&lt;/code&gt;. Calling the function on entry does not mean restoring the data on every entry. The database owns the interval check and records the last successful reset.&lt;/p&gt;
&lt;p&gt;These statements from the &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/supabase/migrations/20261014095433_restore_demo_examples.sql&quot;&gt;restoration function&lt;/a&gt; show the synchronization point and the time rule:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select r.last_reset_at into last_reset
from public.demo_reset_state r where r.id = true for update;

if last_reset &amp;gt; now() - interval &apos;1 hour&apos; then
  return false;
end if;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The row lock holds competing resets behind the same bookkeeping row. Once the first restore completes, the next caller sees its updated timestamp and returns without repeating the work. The function also locks existing example snapshots in the same order as the itinerary writer, before restoring the plans.&lt;/p&gt;
&lt;p&gt;For example, if the last reset ran at 10:00, an entry at 10:30 leaves the baseline untouched and an entry at 11:05 can restore it. With no later dashboard entry, no restore runs. The hour is a minimum interval since the last reset.&lt;/p&gt;
&lt;p&gt;There is no dirty-check. After the interval expires, the function can restore the baseline even if no visitor changed it. Eligibility depends on the timestamp, without comparing the current data to the baseline.&lt;/p&gt;
&lt;h2 id=&quot;restore-the-relationships-that-make-an-example-usable&quot;&gt;Restore the relationships that make an example usable&lt;/h2&gt;
&lt;p&gt;Restoring just the itinerary JSON would leave several ways to break the demo. A deleted plan needs its original ID and public slug back so an existing URL works again. Its metadata, owner membership, expenses, and snapshot must agree with the restored trip.&lt;/p&gt;
&lt;p&gt;The baseline therefore includes plan metadata, the itinerary snapshot, and budget entries. The reset recreates deleted examples, replaces edited example data, restores membership, clears their old event history, and removes extra plans created under the demo account. It also restores the demo profile. A baseline plan that now belongs to a different account causes the function to fail rather than take ownership of that plan.&lt;/p&gt;
&lt;p&gt;Dates need maintenance too. Rome starts yesterday and Boipeba starts in 30 days, relative to &lt;code&gt;America/Bahia&lt;/code&gt;. The restore shifts both plan dates and itinerary day labels while preserving activity IDs and content. A prepared trip stays useful for exploring ongoing and upcoming travel without someone periodically editing its calendar.&lt;/p&gt;
&lt;h2 id=&quot;protect-the-baseline-from-the-account-it-repairs&quot;&gt;Protect the baseline from the account it repairs&lt;/h2&gt;
&lt;p&gt;The demo account can edit product data, but it cannot edit the saved baseline or reset bookkeeping. Those tables have Row-Level Security enabled, and direct privileges are revoked from visitor roles.&lt;/p&gt;
&lt;p&gt;The restore is a &lt;code&gt;security definer&lt;/code&gt; function with an empty &lt;code&gt;search_path&lt;/code&gt; and qualified table names. It checks the caller inside the function: the demo identity can restore itself, and the defined database maintenance roles can run it during setup. Another authenticated account cannot trigger a demo reset merely by calling the RPC.&lt;/p&gt;
&lt;p&gt;A visitor with write access to maintenance data could replace the examples or move the timestamp that decides when they return.&lt;/p&gt;
&lt;h2 id=&quot;the-dashboard-stays-available-when-reset-fails&quot;&gt;The dashboard stays available when reset fails&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/src/features/demo/lib/resetDemoIfStale.ts&quot;&gt;server helper catches reset failures&lt;/a&gt;, allowing the dashboard to continue loading. Restoration is best-effort. An already-open tab is not refreshed automatically, and a failed reset can leave the examples altered until a later entry succeeds.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/d442fc13e62347d8e49a59e1173f6c8b7298288f/src/features/demo/repositories/DemoRepository.integration-test.ts&quot;&gt;integration tests&lt;/a&gt; exercise restoration against a local database, including deleted examples and their URLs, date shifting, access boundaries, and repeated or concurrent reset calls.&lt;/p&gt;
</content:encoded><category>PostgreSQL</category><category>Supabase</category><category>Authentication</category><category>Product Engineering</category></item><item><title>Keeping drag-and-drop stable during realtime updates</title><link>https://andremarinho.me/posts/drag-and-drop-with-realtime</link><guid isPermaLink="true">https://andremarinho.me/posts/drag-and-drop-with-realtime</guid><description>How Turistar separated drag previews from persistence, what a seven-to-one write simulation measured, and why later commits carry a move intention instead of a whole draft.</description><pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A card moving under the pointer can change position many times before someone drops it. In &lt;a href=&quot;https://andremarinho.me/projects/turistar&quot;&gt;Turistar&lt;/a&gt;, those intermediate positions need to look immediate without becoming a sequence of persisted itinerary changes. Remote updates make the problem harder: another screen can change the same plan while the gesture is active.&lt;/p&gt;
&lt;p&gt;The January implementation kept a local draft for the drag and saved the final result once on drop. In an August simulation using that revision&apos;s drag code, a path across a full column and into the next on a four-day board with four activities per day produced seven effective reorder steps. Publishing every step would mean seven writes; committing the result meant one.&lt;/p&gt;
&lt;p&gt;That design also exposed a stale-draft problem. In September I changed the commit boundary again, from submitting the whole itinerary draft to submitting the moved activity&apos;s ID and intended destination.&lt;/p&gt;
&lt;h2 id=&quot;the-original-drag-draft-belonged-to-the-board&quot;&gt;The original drag draft belonged to the board&lt;/h2&gt;
&lt;p&gt;In the &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/acec418455c17b70977bbc64a58e165ab703964f/src/features/activityBoard/components/ActivityBoard.tsx&quot;&gt;January board component&lt;/a&gt;, &lt;code&gt;draftDays&lt;/code&gt; supplied the rendered columns. The drag hook received &lt;code&gt;setDraftDays&lt;/code&gt; for preview changes and a separate callback for the final commit.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;handleDragOver&lt;/code&gt; updated only the draft. Its &lt;code&gt;requestAnimationFrame&lt;/code&gt; scheduling retained the latest hovered target while allowing at most one pending frame callback. That reduced redundant local work without sending an update to the persistence layer on each pointer event.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/acec418455c17b70977bbc64a58e165ab703964f/src/features/activityBoard/hooks/useDragHandlers.ts&quot;&gt;drop handler&lt;/a&gt; resolved the final target, applied any remaining local move, and called &lt;code&gt;onDaysCommit&lt;/code&gt; if a local reorder had occurred. Cancellation cleared the drag state without publishing that draft.&lt;/p&gt;
&lt;h2 id=&quot;freezing-the-draft-kept-remote-state-out-of-the-gesture&quot;&gt;Freezing the draft kept remote state out of the gesture&lt;/h2&gt;
&lt;p&gt;The board copied incoming shared state into its draft only when no activity was being dragged:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  if (!activeId) setDraftDays(days);
}, [activeId, days]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remote events could update the shared &lt;code&gt;days&lt;/code&gt; while the visible draft stayed stable. A remote reorder would therefore not move the dragged card underneath the pointer midway through the gesture. Once the drag ended, the board could synchronize with shared state again.&lt;/p&gt;
&lt;p&gt;A debounce on persistence alone would not provide that behavior. It could delay writes while still letting remote state replace the array that the active gesture was rendering. The draft gave the board a separate state boundary for the duration of the interaction.&lt;/p&gt;
&lt;h2 id=&quot;the-measured-path-had-seven-steps-not-every-drag&quot;&gt;The measured path had seven steps, not every drag&lt;/h2&gt;
&lt;p&gt;The simulation used the real &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/acec418455c17b70977bbc64a58e165ab703964f/src/features/activityBoard/lib/dragUtils.ts&quot;&gt;&lt;code&gt;applyDragMove&lt;/code&gt; function&lt;/a&gt; and counted the steps that returned a changed order. The scenario was four days with four activities per day, moving across a full column and into the next.&lt;/p&gt;
&lt;p&gt;Seven changed intermediate orders could have triggered seven persistence calls if every preview had used the shared-state setter. The local-draft path published one final result. A short drag has fewer intermediate changes, and an unchanged or cancelled drag may publish none. Seven-to-one describes this modeled path.&lt;/p&gt;
&lt;p&gt;This was a code-level simulation, without captured user traffic or a latency measurement.&lt;/p&gt;
&lt;h2 id=&quot;a-whole-draft-can-accidentally-describe-a-deletion&quot;&gt;A whole draft can accidentally describe a deletion&lt;/h2&gt;
&lt;p&gt;The January revision eventually committed the final &lt;code&gt;DayPlan[]&lt;/code&gt;. The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/acec418455c17b70977bbc64a58e165ab703964f/src/features/events/hooks/usePlanCollaboration.ts&quot;&gt;collaboration layer compared that array against its current shared state&lt;/a&gt; to produce events.&lt;/p&gt;
&lt;p&gt;Suppose a second screen added an activity while the first screen was dragging. The shared state could receive the addition, while the frozen draft never contained it. On drop, comparing the current shared state with that old draft could interpret the missing activity as an intentional deletion. A concurrent edit to an existing card could similarly be overwritten by the draft&apos;s older fields.&lt;/p&gt;
&lt;p&gt;The gesture remained visually stable while the eventual commit could still misrepresent what the person intended. Versioning the resulting events did not recover the intent lost in that whole-array comparison.&lt;/p&gt;
&lt;p&gt;That conflict required edits to the same itinerary during the active drag and its commit. I did not measure the race window or its frequency. This limitation belongs to the linked historical revision; the later move contract avoids submitting that whole draft.&lt;/p&gt;
&lt;h2 id=&quot;the-later-commit-describes-a-move&quot;&gt;The later commit describes a move&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/commit/151498387e729ca5759137a7de6a37686452a817&quot;&gt;September change&lt;/a&gt; removed that whole-draft commit path. The hook retains the active activity and destination, using &lt;code&gt;moveActivityOperation&lt;/code&gt; to project a preview over the latest &lt;code&gt;days&lt;/code&gt;. On drop it calls the move callback with an activity ID and an ID-based destination:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;onActivityMove(activityId, {
  toDayId,
  beforeActivityId,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This shows the callback contract; the &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/151498387e729ca5759137a7de6a37686452a817/src/modules/planner/hooks/useDragHandlers.ts&quot;&gt;implementation&lt;/a&gt; resolves those values from the hovered destination. The operation asks to move one activity before another, or to the end of a day. It does not ask to replace every activity with a local copy.&lt;/p&gt;
&lt;p&gt;A &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/151498387e729ca5759137a7de6a37686452a817/src/modules/planner/hooks/useDragHandlers.test.ts&quot;&gt;regression test&lt;/a&gt; starts a drag, supplies updated remote days, and checks that a remote title survives in the preview. It also checks that drop publishes the activity ID and destination, and that cancellation publishes no mutation. The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/151498387e729ca5759137a7de6a37686452a817/src/features/events/lib/planOperations.ts&quot;&gt;operation helper&lt;/a&gt; returns no move for a missing activity or destination.&lt;/p&gt;
&lt;p&gt;The new boundary avoids expressing unrelated remote additions as deletions merely because a drag preview omitted them. Concurrent moves of the same activity still require an ordering policy, and this event-sourced design does not promise CRDT conflict resolution.&lt;/p&gt;
</content:encoded><category>React</category><category>Realtime</category><category>Event Sourcing</category><category>Drag and Drop</category></item><item><title>The partial unique index that broke my Supabase upsert</title><link>https://andremarinho.me/posts/partial-index-supabase-upsert</link><guid isPermaLink="true">https://andremarinho.me/posts/partial-index-supabase-upsert</guid><description>Batching LawFlow notifications exposed a mismatch between a partial unique index and Supabase&apos;s conflict target. Removing a redundant predicate fixed it.</description><pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Opening the notification panel in &lt;a href=&quot;https://andremarinho.me/projects/lawflow&quot;&gt;LawFlow&lt;/a&gt; recalculated reminders from leads, proposals and activities. The calculations could produce notifications that already existed. My repository tried each insert and swallowed PostgreSQL&apos;s &lt;code&gt;23505&lt;/code&gt; unique-violation error to keep the list deduplicated.&lt;/p&gt;
&lt;p&gt;That preserved uniqueness, but an ordinary panel opening could produce dozens of failed inserts. I changed the service to collect candidate rows and write them in one batch. Then the batch failed: PostgreSQL could not find a matching unique constraint for the conflict target.&lt;/p&gt;
&lt;h2 id=&quot;what-the-duplicate-meant&quot;&gt;What the duplicate meant&lt;/h2&gt;
&lt;p&gt;Each notification had a deduplication key identifying the reason it existed, such as a particular reminder for a particular activity. Uniqueness applied to the combination of workspace, recipient and deduplication key. The same event could notify several people without colliding across recipients or workspaces.&lt;/p&gt;
&lt;p&gt;The existing index had this shape:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE UNIQUE INDEX idx_notifications_dedupe_per_user
  ON notifications (workspace_id, user_id, dedupe_key)
  WHERE dedupe_key IS NOT NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The service&apos;s five calculation functions previously wrote rows themselves. I made them synchronous functions that returned candidates, kept the database reads in the orchestration step, and passed the combined rows to one repository method. An empty batch returned immediately.&lt;/p&gt;
&lt;p&gt;The write used Supabase&apos;s upsert API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { error } = await db.from(&quot;notifications&quot;).upsert(payload, {
  onConflict: &quot;workspace_id,user_id,dedupe_key&quot;,
  ignoreDuplicates: true,
});

if (error) throw new Error(error.message);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ignoreDuplicates: true&lt;/code&gt; matters here. The intended conflict action was &lt;code&gt;DO NOTHING&lt;/code&gt;. Recomputing an existing notification should preserve its read status and original timestamp. Updating it from the new payload, which contains &lt;code&gt;read: false&lt;/code&gt;, could make a notification unread again. Supabase&apos;s &lt;a href=&quot;https://supabase.com/docs/reference/javascript/upsert&quot;&gt;upsert reference&lt;/a&gt; describes the conflict columns and the choice controlled by &lt;code&gt;ignoreDuplicates&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;matching-columns-did-not-identify-the-partial-index&quot;&gt;Matching columns did not identify the partial index&lt;/h2&gt;
&lt;p&gt;PostgreSQL infers an arbiter index from the &lt;code&gt;ON CONFLICT&lt;/code&gt; target. A partial index also needs a compatible predicate in that target. The column list alone did not identify this partial index, even though the attempted row had a non-null deduplication key. PostgreSQL documents this distinction in its &lt;a href=&quot;https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT&quot;&gt;conflict-target rules&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In direct SQL, that target could include &lt;code&gt;WHERE dedupe_key IS NOT NULL&lt;/code&gt;. The Supabase upsert API I was using exposed the conflict columns, without an option for the index predicate.&lt;/p&gt;
&lt;p&gt;The table definition made the correction simpler. &lt;code&gt;dedupe_key&lt;/code&gt; was already &lt;code&gt;NOT NULL&lt;/code&gt;. So were &lt;code&gt;workspace_id&lt;/code&gt; and &lt;code&gt;user_id&lt;/code&gt;. Every valid row satisfied the index predicate; it selected no smaller subset of the table.&lt;/p&gt;
&lt;p&gt;I replaced the partial index with a non-partial unique index on the same columns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE UNIQUE INDEX idx_notifications_dedupe_per_user
  ON notifications (workspace_id, user_id, dedupe_key);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These snippets show the before and after definitions, not a migration to run together. The replacement retained uniqueness for every valid row and gave the column-only conflict target a matching index.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;NOT NULL&lt;/code&gt; constraint is important to that reasoning. A normal PostgreSQL unique index permits multiple nulls by default; replacing a partial index does not automatically make nulls equal. That is a separate &lt;a href=&quot;https://www.postgresql.org/docs/current/indexes-unique.html&quot;&gt;unique-index rule&lt;/a&gt;. Here, the schema excluded nulls before either index was considered.&lt;/p&gt;
&lt;h2 id=&quot;why-i-kept-deduplication-in-the-database&quot;&gt;Why I kept deduplication in the database&lt;/h2&gt;
&lt;p&gt;Fetching existing keys first would let the service omit known duplicates, but two requests could both read an absent key before either inserted it. An in-memory set would only coordinate one request. Two browser tabs could still generate the same notification concurrently.&lt;/p&gt;
&lt;p&gt;The unique index remained the authority. &lt;code&gt;DO NOTHING&lt;/code&gt; handled an expected collision at the write boundary, and unrelated database errors still propagated. The batch removed the per-candidate network calls without weakening the protection against concurrent requests.&lt;/p&gt;
&lt;p&gt;Repository tests checked the batch payload, conflict options, timestamp mapping and empty-input behavior. Service tests checked which candidates the rules generated. Those tests helped keep the refactor faithful to the existing notification behavior; mocked repository tests alone would not have exposed the index-inference failure.&lt;/p&gt;
&lt;h2 id=&quot;fewer-statements-and-a-smaller-latency-improvement&quot;&gt;Fewer statements and a smaller latency improvement&lt;/h2&gt;
&lt;p&gt;In an isolated workspace seeded for the comparison, a recomputation produced roughly 30 to 60 candidates. The write path went from that many individual insert statements to one batch statement. Candidate reads and the final inbox query still existed, and the database still checked each proposed row for conflicts.&lt;/p&gt;
&lt;p&gt;Elapsed time improved by roughly 20% to 40% in that scenario. The old inserts already ran concurrently through &lt;code&gt;Promise.all&lt;/code&gt;, so replacing them did not save 30 to 60 sequential round trips. The larger reduction was in statement count and expected-error handling. A smaller workspace would generate fewer candidates and show a smaller absolute reduction.&lt;/p&gt;
&lt;p&gt;Recomputation also remained on the request path whenever the list included unread notifications. Batching changed how candidates were persisted; it did not turn notification generation into a background process.&lt;/p&gt;
</content:encoded><category>PostgreSQL</category><category>Supabase</category><category>TypeScript</category><category>concurrency</category></item><item><title>Fetching dashboard summaries instead of full snapshots</title><link>https://andremarinho.me/posts/dashboard-summaries-instead-of-snapshots</link><guid isPermaLink="true">https://andremarinho.me/posts/dashboard-summaries-instead-of-snapshots</guid><description>Turistar&apos;s dashboard moved from loading itinerary snapshots per destination to one PostgreSQL summary query, while keeping all map destinations and the card limit.</description><pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;https://andremarinho.me/projects/turistar&quot;&gt;Turistar&lt;/a&gt; dashboard shows trip cards and a map of destinations. It needs dates, coordinates, a cover image, and an activity count. It was also fetching the itinerary snapshot for each destination so the application could count those activities.&lt;/p&gt;
&lt;p&gt;I replaced that path with one database call returning dashboard summaries. In a controlled benchmark with 50 plans containing 40 activities each and 30 ms of added database API latency, median data-loading time fell from 129.10 ms to 39.03 ms, a reduction of 69.77%. Those timings measure HTTP request to parsed JSON, not full-page rendering.&lt;/p&gt;
&lt;p&gt;The change shipped in &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/pull/518&quot;&gt;PR #518&lt;/a&gt;. The dashboard receives the values it displays, while PostgreSQL reads the snapshots needed to calculate them.&lt;/p&gt;
&lt;h2 id=&quot;one-screen-was-loading-through-several-paths&quot;&gt;One screen was loading through several paths&lt;/h2&gt;
&lt;p&gt;Before the change, the page loaded plans and destinations through separate paths, including a snapshot read per destination. An itinerary snapshot contains the days and activity records used by the planner. Most of those fields have no use in a dashboard summary.&lt;/p&gt;
&lt;p&gt;The application still had to receive and parse that content to derive a count. The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/151498387e729ca5759137a7de6a37686452a817/src/features/plan/repositories/PlanRepository.ts&quot;&gt;per-destination snapshot reads already ran through &lt;code&gt;Promise.all&lt;/code&gt;&lt;/a&gt;. They were parallel, but each still crossed the database API boundary and transferred the snapshot content.&lt;/p&gt;
&lt;p&gt;I introduced &lt;code&gt;get_user_plan_summaries&lt;/code&gt;, a query dedicated to this screen, and changed the page to call &lt;code&gt;PlanService.getUserDashboard()&lt;/code&gt;. The service receives summary rows once and derives both the trip cards and map destinations from them.&lt;/p&gt;
&lt;h2 id=&quot;count-inside-the-query-return-the-summary&quot;&gt;Count inside the query, return the summary&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/6c045f98bcd3ecc685358bc52b13ba22c47f93c3/supabase/migrations/20260912015840_dashboard_read_model.sql&quot;&gt;SQL function&lt;/a&gt; first collects the plan IDs the caller owns or belongs to. It uses &lt;code&gt;UNION&lt;/code&gt;, so a plan present through both relationships appears once. It joins those plans to their stored snapshots and calculates the count in SQL.&lt;/p&gt;
&lt;p&gt;This is the activity-count expression from the function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;coalesce((
  select sum(case when jsonb_typeof(day-&amp;gt;&apos;activities&apos;) = &apos;array&apos;
    then jsonb_array_length(day-&amp;gt;&apos;activities&apos;) else 0 end)
  from jsonb_array_elements(case when jsonb_typeof(ps.state-&amp;gt;&apos;days&apos;) = &apos;array&apos;
    then ps.state-&amp;gt;&apos;days&apos; else &apos;[]&apos;::jsonb end) as day
), 0)::bigint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The JSON type checks give missing or non-array day and activity collections a defined count behavior. A missing snapshot is still allowed by the left join and yields a zero count. The application receives &lt;code&gt;activity_count&lt;/code&gt; alongside the plan&apos;s display fields; it no longer needs &lt;code&gt;state&lt;/code&gt; to reproduce the traversal.&lt;/p&gt;
&lt;p&gt;This is a query over the existing data, without a separate summary table or background synchronization job. PostgreSQL still traverses the relevant JSON to count activities. One RPC reduces request overhead and transferred content; it does not make query work independent of the amount of itinerary data.&lt;/p&gt;
&lt;h2 id=&quot;keep-authorization-and-display-limits-explicit&quot;&gt;Keep authorization and display limits explicit&lt;/h2&gt;
&lt;p&gt;The RPC is &lt;code&gt;security invoker&lt;/code&gt;, so it executes under the caller&apos;s database permissions and Row-Level Security policies. Execution is granted to authenticated accounts and revoked from anonymous access. The summary query keeps authorization at the data boundary while selecting owned and shared plans.&lt;/p&gt;
&lt;p&gt;Cards and the map have different limits. At this revision, the dashboard displayed the latest 50 plan cards, but the map needed the complete destination history. Putting &lt;code&gt;LIMIT 50&lt;/code&gt; in the shared query would quietly remove destinations from the map.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/6c045f98bcd3ecc685358bc52b13ba22c47f93c3/src/features/plan/services/PlanService.ts&quot;&gt;service therefore limits only the cards&lt;/a&gt;. It maps all summary rows with a destination into map entries. A plan without a destination can still have a card without producing a blank map location. The previous RPC remained available during rollout so older application instances could continue using it.&lt;/p&gt;
&lt;h2 id=&quot;what-the-timing-establishes&quot;&gt;What the timing establishes&lt;/h2&gt;
&lt;p&gt;The controlled comparison uses the same 50-plan, 40-activity workload and 30 ms added API latency for both paths. Reducing median time from 129.10 ms to 39.03 ms gives:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;(129.10 - 39.03) / 129.10 = 69.77%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also observed a reduction of about 70% in dashboard data loading in production. I have not retained the absolute production timings, traces, or sampling protocol, so the two precise millisecond values above belong only to the controlled benchmark. Neither observation establishes an equivalent improvement in LCP, login time, or complete page rendering.&lt;/p&gt;
&lt;p&gt;The benchmark includes database API latency, which makes avoiding per-destination calls relevant to this workload. A smaller plan history or a different network environment can produce a different reduction.&lt;/p&gt;
&lt;h2 id=&quot;check-that-less-data-still-produces-the-same-screen&quot;&gt;Check that less data still produces the same screen&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/6c045f98bcd3ecc685358bc52b13ba22c47f93c3/src/features/plan/repositories/PlanRepository.test.ts&quot;&gt;repository tests&lt;/a&gt; check the summary RPC call and error handling. The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/6c045f98bcd3ecc685358bc52b13ba22c47f93c3/src/features/plan/services/PlanService.test.ts&quot;&gt;service tests&lt;/a&gt; use 55 rows to assert that only 50 become cards while all 55 remain available to the map. They also cover empty data, destination-less plans, zero coordinates, dates, authentication, and propagation of failures.&lt;/p&gt;
&lt;p&gt;Those checks constrain the optimization to the dashboard&apos;s existing contract. A response can become smaller and faster while accidentally losing a shared plan or truncating the travel history; the read model needs to preserve those behaviors as explicitly as it removes the full snapshots.&lt;/p&gt;
</content:encoded><category>PostgreSQL</category><category>Supabase</category><category>Performance</category><category>Read Models</category></item><item><title>Removing the login-to-dashboard waterfall in Next.js</title><link>https://andremarinho.me/posts/removing-login-dashboard-waterfall</link><guid isPermaLink="true">https://andremarinho.me/posts/removing-login-dashboard-waterfall</guid><description>How I moved LawFlow&apos;s authenticated bootstrap to the server, preserved billing gates and stopped the browser from rebuilding the same initial state.</description><pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The slow part of entering &lt;a href=&quot;https://andremarinho.me/projects/lawflow&quot;&gt;LawFlow&lt;/a&gt; crossed several systems. After authentication, the browser hydrated its session, resolved the workspace, checked billing access, loaded shared workspace data and finally mounted screens that could request their own data.&lt;/p&gt;
&lt;p&gt;The user had to wait for a chain of dependent browser requests. Optimizing a query helped its own stage while leaving the rest of that chain intact.&lt;/p&gt;
&lt;p&gt;I changed the authenticated entry route to resolve an authorized bootstrap on the server and pass the result into the client providers. The browser could initialize from that snapshot instead of discovering the workspace and rebuilding the shared state after mounting.&lt;/p&gt;
&lt;h2 id=&quot;following-the-dependencies-across-providers&quot;&gt;Following the dependencies across providers&lt;/h2&gt;
&lt;p&gt;The loading path had grown as authentication, multi-tenancy and billing were added. Session polling delayed workspace resolution. A billing gate controlled whether parts of the application mounted, so their data requests started only after that gate finished. Other paths repeated workspace or authorization lookups through separate calls.&lt;/p&gt;
&lt;p&gt;My first changes stayed within that structure. I replaced session polling with explicit session hydration, let independent billing and bootstrap work run in parallel, and made a detailed subscription-status request conditional on denied access. A user already allowed by the entitlement check did not need that extra request before entering.&lt;/p&gt;
&lt;p&gt;The intermediate flow still took approximately 5.7 to 6 seconds in production. The request map at that stage contained about ten authentication or database operations on the successful path, even though some were parallel or shared an HTTP batch. Counting HTTP requests alone hid work performed inside each request.&lt;/p&gt;
&lt;p&gt;The remaining question was which information the browser actually needed to discover for itself. The server already knew the authenticated user before rendering the entry page. Resolving the same user&apos;s workspace after that page mounted added another dependency boundary.&lt;/p&gt;
&lt;h2 id=&quot;one-entry-route-produces-the-initial-state&quot;&gt;One entry route produces the initial state&lt;/h2&gt;
&lt;p&gt;The authenticated page now follows this shape:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const session = await getServerSession();
if (!session) redirect(&quot;/login&quot;);

const initialBootstrap = await resolveCrmShellData(session);

return &amp;lt;CrmView initialBootstrap={initialBootstrap} /&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resolver verifies the workspace for that authenticated user, reads the workspace context needed by billing, resolves access and then loads the authorized bootstrap data. Reusing the workspace row avoids fetching it again inside billing.&lt;/p&gt;
&lt;p&gt;The snapshot contains shared application state: the current user&apos;s profile, workspace members, office settings, field configuration, notification settings and onboarding status. Client providers initialize their state from those values. Their mount no longer starts a second bootstrap through &lt;code&gt;useEffect&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Screen-specific queries still exist. Moving the bootstrap did not require loading every lead, proposal or dashboard result before returning the page. The boundary covers the shared state required to enter the CRM.&lt;/p&gt;
&lt;p&gt;Next.js supports passing serializable data from Server Components into Client Components through props. That is the &lt;a href=&quot;https://nextjs.org/docs/app/getting-started/server-and-client-components&quot;&gt;server/client boundary&lt;/a&gt; used here. A prop reaches the client, so the bootstrap has an explicit shape rather than passing through an unrestricted database result.&lt;/p&gt;
&lt;h2 id=&quot;access-checks-determine-which-snapshot-can-exist&quot;&gt;Access checks determine which snapshot can exist&lt;/h2&gt;
&lt;p&gt;Moving work to the server also made the authorization order explicit. The resolver derives the workspace from the verified caller; it does not trust a workspace selected in the browser. It loads application bootstrap data only after billing permits access.&lt;/p&gt;
&lt;p&gt;Denied access produces a recovery snapshot with the information needed to show the billing gate. Failure to check billing produces an unavailable state with a retry path. Neither branch falls through to an authorized CRM with empty defaults. A missing workspace is handled as a workspace error rather than being relabeled as a billing outage.&lt;/p&gt;
&lt;p&gt;API operations retain their own authorization checks. The snapshot reflects authorization for that render; it is not permission for later mutations. Removing repeated checks within that render did not remove the checks at the API boundary.&lt;/p&gt;
&lt;p&gt;The login action needed attention too. After it wrote the session cookies, the client refreshed its router state and navigated to &lt;code&gt;/&lt;/code&gt;. That entry request produced the bootstrap against the fresh session. Returning success from the action avoided rendering the same bootstrap inside a server-action redirect and then doing it again during navigation.&lt;/p&gt;
&lt;h2 id=&quot;measuring-the-complete-login-flow&quot;&gt;Measuring the complete login flow&lt;/h2&gt;
&lt;p&gt;The original path took approximately 7,441 milliseconds from clicking Log in to the rendered CRM. After the server bootstrap changes, measurements during the implementation were approximately 760 milliseconds for that same full flow, about a 90% reduction.&lt;/p&gt;
&lt;p&gt;These are approximate end-to-end measurements from the implementation. I did not preserve the final raw traces or a reproducible set of runs. The intermediate production measurements and request map were recorded, but the final numbers should not be read as a controlled benchmark or a latency guarantee for every workspace and network condition.&lt;/p&gt;
&lt;p&gt;Tests covered the resolver&apos;s authorized, denied and unavailable branches, snapshot mapping and login-action failures.&lt;/p&gt;
&lt;p&gt;Server-side loading still has a critical path. Authentication must precede workspace resolution, and access must precede loading protected application data. The improvement came from placing those dependencies in one server-owned flow, reusing resolved context and letting the client begin with the result.&lt;/p&gt;
</content:encoded><category>Next.js</category><category>React</category><category>Supabase</category><category>performance</category><category>authentication</category></item><item><title>When the event version was current but the snapshot wasn&apos;t</title><link>https://andremarinho.me/posts/event-version-and-stale-snapshot</link><guid isPermaLink="true">https://andremarinho.me/posts/event-version-and-stale-snapshot</guid><description>A Turistar persistence bug let snapshot versions advance while retaining old content. The fix builds snapshot state on the server and sends it with the events.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;While investigating stored itinerary data in &lt;a href=&quot;https://andremarinho.me/projects/turistar&quot;&gt;Turistar&lt;/a&gt; with SQL, I found that the snapshot content did not match the event history. The snapshot version could advance successfully while its itinerary stayed old or empty.&lt;/p&gt;
&lt;p&gt;Appending the events advanced their version. Updating the snapshot content required an optional argument that the caller wasn&apos;t sending. The database accepted the append and recorded the new version, leaving readers with a misleading checkpoint.&lt;/p&gt;
&lt;p&gt;The investigation led to a server-side snapshot builder, shipped in &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/commit/7ef57059a9e79eaa7928b2e904016bdedb224ecf&quot;&gt;the January 2026 change&lt;/a&gt;. It applies the events before persistence and sends the calculated state in the same database call as the event batch.&lt;/p&gt;
&lt;h2 id=&quot;what-a-snapshot-version-is-supposed-to-mean&quot;&gt;What a snapshot version is supposed to mean&lt;/h2&gt;
&lt;p&gt;Turistar transports itinerary changes as events such as &lt;code&gt;activity.created&lt;/code&gt;, &lt;code&gt;activity.moved&lt;/code&gt;, and &lt;code&gt;day.reordered&lt;/code&gt;. Each accepted event receives a monotonically increasing version within its plan. Other clients apply those events through the shared reducer.&lt;/p&gt;
&lt;p&gt;A snapshot lets a reader start from a materialized itinerary instead of rebuilding the plan from its first event. For that to work, the snapshot&apos;s version must describe the events already reflected in its content:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;snapshot at version v + events v+1 through n = itinerary at version n
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the snapshot claims version &lt;code&gt;n&lt;/code&gt; but still contains the itinerary from an earlier version, a client requesting only events after &lt;code&gt;n&lt;/code&gt; cannot recover the missing changes. Comparing version numbers will not expose the missing content because the stored number itself is current.&lt;/p&gt;
&lt;h2 id=&quot;the-caller-omitted-the-content-argument&quot;&gt;The caller omitted the content argument&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/d4a77936cf63cca562ef53c1761f47402a8c59fc/src/features/app/planner/server/repositories/PlanEventsRepository.ts&quot;&gt;repository before the fix&lt;/a&gt; called &lt;code&gt;append_plan_events&lt;/code&gt; with &lt;code&gt;plan_id&lt;/code&gt;, &lt;code&gt;base_version&lt;/code&gt;, and &lt;code&gt;events&lt;/code&gt;. It did not include &lt;code&gt;snapshot_state&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/0cb77827fe3979d0dcd7adc7dd928b5e4ad1e2d8/supabase/migrations/20250212134000_append_plan_events.sql&quot;&gt;SQL function accepted that omission&lt;/a&gt;: &lt;code&gt;snapshot_state&lt;/code&gt; defaulted to &lt;code&gt;NULL&lt;/code&gt;. After inserting the events, the update used this expression:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE plan_snapshots
SET version = new_version,
    state = COALESCE(snapshot_state, plan_snapshots.state),
    updated_at = NOW()
WHERE plan_snapshots.plan_id = append_plan_events.plan_id;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With no new state supplied, &lt;code&gt;COALESCE&lt;/code&gt; deliberately retained the previous state while the version advanced. When the function had to insert a missing snapshot, the equivalent fallback was &lt;code&gt;{&quot;days&quot;:[]}&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The event append and snapshot update could run together successfully and still produce an invalid checkpoint. Transactional execution could not compensate for the caller supplying events without their corresponding materialized state.&lt;/p&gt;
&lt;h2 id=&quot;build-the-next-state-before-appending&quot;&gt;Build the next state before appending&lt;/h2&gt;
&lt;p&gt;The corrected &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/7ef57059a9e79eaa7928b2e904016bdedb224ecf/src/features/events/services/eventsQueries.ts&quot;&gt;server append flow&lt;/a&gt; fetches the current snapshot, passes it and the incoming events to &lt;code&gt;buildSnapshotStateForAppend&lt;/code&gt;, then supplies the resulting &lt;code&gt;snapshot_state&lt;/code&gt; to the repository.&lt;/p&gt;
&lt;p&gt;For a snapshot whose version matches &lt;code&gt;baseVersion&lt;/code&gt;, the builder assigns the incoming events consecutive versions beginning at &lt;code&gt;baseVersion + 1&lt;/code&gt;. It applies them with the same reducer used to interpret the event history and serializes the resulting days as JSON-safe state.&lt;/p&gt;
&lt;p&gt;The database still checks &lt;code&gt;base_version&lt;/code&gt; under its snapshot row lock. If another append wins between the server&apos;s read and its write, the database sees the version mismatch and returns the current version without inserting that batch. Calculating a snapshot on the server does not replace the database&apos;s concurrency check.&lt;/p&gt;
&lt;p&gt;When the versions match and the server supplies the calculated state, the RPC writes events and their resulting snapshot together. The client does not need to follow a successful append with a separate snapshot update that could fail independently.&lt;/p&gt;
&lt;h2 id=&quot;recover-a-known-empty-snapshot-failure&quot;&gt;Recover a known empty-snapshot failure&lt;/h2&gt;
&lt;p&gt;An empty snapshot with a nonzero version is a specific sign that this recovery path can investigate. The server fetches the plan&apos;s history from version zero, and the &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/7ef57059a9e79eaa7928b2e904016bdedb224ecf/src/features/snapshots/services/snapshotStateBuilder.ts&quot;&gt;snapshot builder&lt;/a&gt; checks that the history&apos;s highest version equals the requested base version before rebuilding.&lt;/p&gt;
&lt;p&gt;It replays that history onto empty days at version zero, then applies the incoming batch. This lets a later append reconstruct an empty materialization from the retained log, rather than treating the empty days as the correct starting point.&lt;/p&gt;
&lt;p&gt;There are limits to that recovery. The builder does not inspect every nonempty snapshot for incorrect content, and checking the highest history version is not a proof that every expected event is present. Returning &lt;code&gt;null&lt;/code&gt; from the builder also does not itself reject an append: the caller can still invoke the RPC without a replacement state. The fix addresses the normal state-building path and the identified empty-snapshot case; it is not a general audit of every stored checkpoint.&lt;/p&gt;
&lt;h2 id=&quot;test-state-as-well-as-versions&quot;&gt;Test state as well as versions&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/andre-lmarinho/turistar/blob/7ef57059a9e79eaa7928b2e904016bdedb224ecf/src/features/snapshots/services/snapshotStateBuilder.test.ts&quot;&gt;builder tests added with the change&lt;/a&gt; check version mismatch, empty input, missing recovery history, and the resulting activity content for a valid append. An assertion that the version increased would have missed the original problem.&lt;/p&gt;
&lt;p&gt;I have not retained the exact SQL queries used during the investigation or a count of affected people. The repository history preserves the omitted argument, the SQL fallback, and the state-building change.&lt;/p&gt;
</content:encoded><category>PostgreSQL</category><category>Event Sourcing</category><category>Supabase</category><category>Data Integrity</category></item><item><title>Why my Postgres pagination cursor needs a timestamp and an ID</title><link>https://andremarinho.me/posts/postgres-cursor-timestamp-and-id</link><guid isPermaLink="true">https://andremarinho.me/posts/postgres-cursor-timestamp-and-id</guid><description>LawFlow&apos;s notification history uses a composite cursor to keep timestamp ties stable, avoid deep offsets and detect the next page without a total count.</description><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The read-notification history in &lt;a href=&quot;https://andremarinho.me/projects/lawflow&quot;&gt;LawFlow&lt;/a&gt; used offset pagination. The interface only needed to load older notifications, but the query described its position as a number of rows to skip. Each deeper request asked PostgreSQL to compute and discard a larger prefix of the result.&lt;/p&gt;
&lt;p&gt;I replaced the offset with a cursor containing &lt;code&gt;sent_at&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt;. The timestamp located the boundary in the history; the ID made that boundary unambiguous when several notifications shared a timestamp.&lt;/p&gt;
&lt;h2 id=&quot;a-timestamp-does-not-completely-order-the-inbox&quot;&gt;A timestamp does not completely order the inbox&lt;/h2&gt;
&lt;p&gt;Notifications can share &lt;code&gt;sent_at&lt;/code&gt;, including several generated in the same batch. Ordering only by that field leaves their relative order unspecified.&lt;/p&gt;
&lt;p&gt;Suppose a page ends partway through a group with the same timestamp. A next-page predicate of &lt;code&gt;sent_at &amp;lt; lastTimestamp&lt;/code&gt; skips the rest of that group. Changing it to &lt;code&gt;&amp;lt;=&lt;/code&gt; can return rows already displayed. Increasing the timestamp&apos;s precision would still leave correctness dependent on timestamps being unique.&lt;/p&gt;
&lt;p&gt;The query therefore orders by both fields:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ORDER BY sent_at DESC, id DESC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The primary-key ID supplies a unique tie-breaker. It does not need to encode creation time because &lt;code&gt;sent_at&lt;/code&gt; provides the chronological order. The same two fields must appear in the cursor and in the continuation condition.&lt;/p&gt;
&lt;h2 id=&quot;continue-strictly-after-the-last-returned-row&quot;&gt;Continue strictly after the last returned row&lt;/h2&gt;
&lt;p&gt;A shortened SQL equivalent of the paginated query is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, sent_at, title
FROM notifications
WHERE workspace_id = $1
  AND user_id = $2
  AND read = true
  AND (
    sent_at &amp;lt; $3
    OR (sent_at = $3 AND id &amp;lt; $4)
  )
ORDER BY sent_at DESC, id DESC
LIMIT $5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here &lt;code&gt;$3&lt;/code&gt; and &lt;code&gt;$4&lt;/code&gt; are the last displayed row&apos;s timestamp and ID; &lt;code&gt;$5&lt;/code&gt; is the requested page size plus one. The first page omits the cursor predicate. Workspace, recipient and read-status filters remain on every request. A cursor identifies a position within that authorized query; it grants no access by itself.&lt;/p&gt;
&lt;p&gt;Because the order is descending, older timestamps come after the boundary. At the same timestamp, smaller IDs come after it. Using &lt;code&gt;&amp;lt;&lt;/code&gt; on both branches keeps the boundary exclusive. Changing the sort direction would require changing these comparisons too.&lt;/p&gt;
&lt;p&gt;The Supabase repository expresses the same condition through &lt;code&gt;.or()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;query.or(
  `sent_at.lt.${cursor.sentAt},and(sent_at.eq.${cursor.sentAt},id.lt.${cursor.id})`,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The API validates the cursor as a timestamp and UUID before it reaches that filter. The client takes the next cursor from the last row actually displayed, preserving the timestamp value returned by the server.&lt;/p&gt;
&lt;h2 id=&quot;one-extra-row-answers-the-uis-question&quot;&gt;One extra row answers the UI&apos;s question&lt;/h2&gt;
&lt;p&gt;For a requested page size of 20, the repository asks for at most 21 rows. It returns the first 20 and sets &lt;code&gt;hasMore&lt;/code&gt; when the result contained the extra row. The client continues from row 20. Using row 21 as the cursor would skip a notification that had never been displayed.&lt;/p&gt;
&lt;p&gt;An empty result ends the traversal. A result with exactly 20 rows and no extra row also has &lt;code&gt;hasMore: false&lt;/code&gt;. This answers whether another page existed when the query ran, without requesting an exact total just to draw a Load more control.&lt;/p&gt;
&lt;p&gt;The interface gives up direct jumps to an arbitrary page number. That fits an inbox where the action is to read further back. A numbered reporting table could require a different tradeoff.&lt;/p&gt;
&lt;h2 id=&quot;the-indexes-still-determine-how-much-work-remains&quot;&gt;The indexes still determine how much work remains&lt;/h2&gt;
&lt;p&gt;PostgreSQL&apos;s &lt;a href=&quot;https://www.postgresql.org/docs/current/queries-limit.html&quot;&gt;LIMIT and OFFSET documentation&lt;/a&gt; explains both requirements behind the change: pagination needs a predictable order, and skipped offset rows still require work on the server. Adding an index to an offset query does not remove that skipped prefix.&lt;/p&gt;
&lt;p&gt;The cursor migration added a B-tree index on &lt;code&gt;(sent_at DESC, id DESC)&lt;/code&gt;. The schema already had another index on &lt;code&gt;(workspace_id, user_id, sent_at DESC)&lt;/code&gt;. Those are different access paths. The first matches the complete ordering but does not begin with the tenant and recipient filters. The second begins with those filters but does not contain the ID tie-breaker or the read-status filter.&lt;/p&gt;
&lt;p&gt;Neither index covers all the filters and the complete ordering. PostgreSQL still chooses a plan and may filter rows or sort. The &lt;a href=&quot;https://www.postgresql.org/docs/current/indexes-ordering.html&quot;&gt;index-ordering rules&lt;/a&gt; explain when a B-tree can supply ordered results directly. Tenant distribution, read-status selectivity and timestamp ties still matter.&lt;/p&gt;
&lt;p&gt;I compared the offset and cursor versions with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, checking the first and fiftieth pages against a seeded history of roughly 50,000 notifications. This was preventive work: the product&apos;s actual volume did not show a noticeable user-facing difference. The comparison supported removing offset-driven degradation in that scenario; it did not establish constant latency for every dataset.&lt;/p&gt;
&lt;h2 id=&quot;stable-boundaries-do-not-freeze-the-history&quot;&gt;Stable boundaries do not freeze the history&lt;/h2&gt;
&lt;p&gt;Newer notifications can arrive above the current boundary without shifting the cursor&apos;s position as they would shift an offset. That does not give the traversal a database snapshot across requests. Deletions, notifications moving between read and unread, or changes to ordering fields can still change what later pages contain.&lt;/p&gt;
&lt;p&gt;The client refreshes read history from the first page when it needs a fresh view and merges loaded notifications by ID. The composite cursor solves the ordering and continuation problem for that flow. It does not promise a permanent, unchanged enumeration of a live inbox.&lt;/p&gt;
</content:encoded><category>PostgreSQL</category><category>Supabase</category><category>pagination</category><category>performance</category></item><item><title>Hello World</title><link>https://andremarinho.me/posts/hello-world</link><guid isPermaLink="true">https://andremarinho.me/posts/hello-world</guid><description>Why I built this site from scratch and started writing about the decisions, bugs, and mistakes behind my projects.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;console.log(&quot;Hello, world&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The site has been up for a while. The blog has not, mostly because I kept deciding I had nothing finished enough to write about.&lt;/p&gt;
&lt;p&gt;This runs on Next.js, TypeScript and Tailwind. Posts are markdown files in a folder, which is enough for this small blog. I would rather add a CMS or database when maintaining the files becomes a problem. Everything renders to static HTML at build time.&lt;/p&gt;
&lt;p&gt;I wrote it from scratch instead of starting from a template, which cost me a weekend I could have skipped. Worth it anyway. A portfolio claiming I write software should be software I wrote.&lt;/p&gt;
&lt;h2 id=&quot;some-context-on-who-is-typing&quot;&gt;Some context on who is typing&lt;/h2&gt;
&lt;p&gt;I ran Duonorth from 2017 to 2025. It began as a business intelligence consultancy, expanded into digital strategy and marketing around 2020, and focused on websites and software from 2023. In July 2025, software product development became my main occupation.&lt;/p&gt;
&lt;p&gt;Current projects include &lt;a href=&quot;https://andremarinho.me/projects/turistar&quot;&gt;Turistar&lt;/a&gt;, an open-source planner for my own trips; &lt;a href=&quot;https://andremarinho.me/projects/create-mvp&quot;&gt;create-mvp&lt;/a&gt;, a CLI on npm for the project setup I got tired of assembling by hand; and &lt;a href=&quot;https://andremarinho.me/projects&quot;&gt;client work&lt;/a&gt; in production.&lt;/p&gt;
&lt;p&gt;Each project leaves me with decisions I want to revisit. Some are wrong, and I would like them written down somewhere they can be argued with.&lt;/p&gt;
&lt;h2 id=&quot;what-goes-here&quot;&gt;What goes here&lt;/h2&gt;
&lt;p&gt;I want to write about things I built and how they broke, including choices I later undid and the bug that ate an afternoon when it should have taken ten minutes. Some of those will only need a short note.&lt;/p&gt;
&lt;p&gt;Two are up already: &lt;a href=&quot;https://andremarinho.me/posts/learning-to-code-with-ai&quot;&gt;what changes when AI writes most of your code&lt;/a&gt;, and &lt;a href=&quot;https://andremarinho.me/posts/before-i-called-it-code&quot;&gt;the work that led me here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Code for this site is on &lt;a href=&quot;https://github.com/andre-lmarinho/me&quot;&gt;GitHub&lt;/a&gt;. If I got something wrong, tell me.&lt;/p&gt;
</content:encoded><category>meta</category><category>nextjs</category><category>writing</category></item><item><title>Learning While AI Writes the Code</title><link>https://andremarinho.me/posts/learning-to-code-with-ai</link><guid isPermaLink="true">https://andremarinho.me/posts/learning-to-code-with-ai</guid><description>How I use AI to build software while making time to understand the code, investigate failures, and test my assumptions.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;It works and I don&apos;t know why.&lt;/p&gt;
&lt;p&gt;That is the most dangerous sentence I can say about my own code, and it got very easy to say. By the time software product development became my main focus in July 2025, I had already spent years building automations and websites. Now I use AI while going deeper into TypeScript, databases, authentication and automated tests.&lt;/p&gt;
&lt;p&gt;When I encounter something unfamiliar, a tool can generate a working solution before I understand why it works. It is tempting to accept the code and move on: the interface works, the tests pass, and the gap stays out of sight.&lt;/p&gt;
&lt;p&gt;I use AI every day, and I would be slower and poorer without it. What I want to understand is how much I learn when the machine hands me working code, and how much I simply collect.&lt;/p&gt;
&lt;h2 id=&quot;the-failure-that-teaches-and-the-one-that-doesnt&quot;&gt;The failure that teaches, and the one that doesn&apos;t&lt;/h2&gt;
&lt;p&gt;Broken code fails loudly. You get a stack trace, a red test, a page that will not load. You fix it, and in fixing it you learn where you were wrong. That was the old deal: the bug was the tuition.&lt;/p&gt;
&lt;p&gt;The deal is still on offer, but the window is much shorter. Paste the error back, the fix comes down, the page loads, and you can be past it in forty seconds without ever having formed a theory of what went wrong. The failure still happens. Whether it teaches anything now depends entirely on whether you stopped to look, and there is a great deal of pressure not to stop.&lt;/p&gt;
&lt;p&gt;Working code I do not understand can leave that gap hidden until a requirement changes or an edge case shows up in production. Sometimes someone asks why I built it that way, and all I can say is that a model suggested it and the tests were green.&lt;/p&gt;
&lt;p&gt;The place this scares me most is multi-tenancy. A mistake in an access policy can expose one account’s data to another while the application appears to work normally. A passing test suite only helps if it checks that boundary. I need to understand what the policy allows and test who can access each record.&lt;/p&gt;
&lt;h2 id=&quot;i-had-seen-this-before-in-marketing&quot;&gt;I had seen this before, in marketing&lt;/h2&gt;
&lt;p&gt;When Duonorth expanded into marketing around 2020, I saw a similar problem with campaign strategies.&lt;/p&gt;
&lt;p&gt;Someone would find a competitor&apos;s funnel and copy it. Same offer structure, same ad angles, same email sequence, same landing page. Sometimes it worked for a while, which was the worst possible outcome, because it confirmed the method.&lt;/p&gt;
&lt;p&gt;Then something moved. The traffic source got more expensive, the audience shifted, the competitor dropped their price. And there was nothing to do, because there was no model of why any of it had been built that way in the first place. You cannot adjust a strategy you do not have. You can only go copy a newer one.&lt;/p&gt;
&lt;p&gt;AI-generated code can leave me in the same position, even when it draws on more codebases than I will read in my life. I still need to understand why this approach fits before I can adapt it to a change.&lt;/p&gt;
&lt;h2 id=&quot;what-understanding-buys-you-concretely&quot;&gt;What understanding buys you, concretely&lt;/h2&gt;
&lt;p&gt;Every problem I actually sat with turns into a better instruction the next time. The first version of a feature gets described as what I want. The second version gets described as what I want, minus the four things I now know go wrong. That second description is worth more than the code that came out of the first one, and it is not something a model can hand me. It is the residue of having watched something break and bothered to find out why.&lt;/p&gt;
&lt;p&gt;There is a second thing, harder to justify on a timesheet. When the mechanical part is handled, there is room to be curious about the domain instead of the syntax. I have read more about how Postgres actually evaluates a policy, or what a queue guarantees and does not, than I would have if I had been heads-down fighting the language. I have to choose to spend that time investigating.&lt;/p&gt;
&lt;h2 id=&quot;what-i-actually-do-about-it&quot;&gt;What I actually do about it&lt;/h2&gt;
&lt;p&gt;These are the habits I use to catch myself accepting code before I understand it.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Ask for the reasoning before the code.&lt;/strong&gt; What were the alternatives, and why this one. I read that first. If the answer comes back generic, the code is usually generic too, and that is a signal about the prompt more than the model.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Explain it out loud before merging.&lt;/strong&gt; I do this even when I am alone. If I cannot say what a block does and what would break without it, I need to read it more carefully.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Delete and rewrite the parts I will have to live with.&lt;/strong&gt; For the core of a feature, I close the suggestion and write it again from memory. It is slow, so I reserve it for code I know I will come back to. If I cannot reproduce it, I need to work through it again.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Write down why.&lt;/strong&gt; &lt;a href=&quot;https://andremarinho.me/projects/create-mvp&quot;&gt;create-mvp&lt;/a&gt; has 35 architecture decision records in it. Explaining why I chose one approach over another makes it obvious when I cannot justify a decision yet. The &lt;a href=&quot;https://github.com/andre-lmarinho/create-app-mvp/blob/0d617a39b391709c1c4bf08bd45e68b2ef902305/docs/decisions.md&quot;&gt;decision records are public&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Break it on purpose.&lt;/strong&gt; Change a value, remove a guard, delete a line, and watch what fails. If nothing fails, either the code does nothing or the tests do not cover it, and both of those are worth finding out on a Tuesday instead of in production.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-i-still-dont-know&quot;&gt;What I still don&apos;t know&lt;/h2&gt;
&lt;p&gt;The concern I cannot dismiss is that iterating faster means encountering more problems per hour and understanding each one less. I have opinions about a lot of things I have only met once, through a failure that got fixed quickly, and I cannot always tell which of those I know and which I merely recognize.&lt;/p&gt;
&lt;p&gt;Questions I cannot answer about code that is already running show me where I need to spend more time. I have collected a few of those.&lt;/p&gt;
&lt;p&gt;AI is very good at the fifth CRUD form, at wiring I have already wired four times, at the boilerplate that teaches nothing on the sixth repetition. It is better than any tutorial I have found at explaining unfamiliar code, which turns out to be most of the job. It helps me move faster, which makes it more important to keep track of the decisions I can explain and the ones I still need to study.&lt;/p&gt;
&lt;p&gt;I wrote about &lt;a href=&quot;https://andremarinho.me/posts/before-i-called-it-code&quot;&gt;how my earlier work led me here&lt;/a&gt;, and about &lt;a href=&quot;https://andremarinho.me/posts/hello-world&quot;&gt;why this site exists&lt;/a&gt;. If you think I have this wrong, I would like to hear it.&lt;/p&gt;
</content:encoded><category>ai</category><category>learning</category><category>career</category></item><item><title>I Was Building Software Before I Called It Code</title><link>https://andremarinho.me/posts/before-i-called-it-code</link><guid isPermaLink="true">https://andremarinho.me/posts/before-i-called-it-code</guid><description>How work in finance, logistics and business intelligence led me to software product development.</description><pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In 2014 I worked in the finance department at Grupo Hemocat, a medical device distributor in Salvador. The finance manager updated a planning spreadsheet by hand, copying information from a printed system report almost every day.&lt;/p&gt;
&lt;p&gt;I asked her if I could try to automate it. That became my first technical project: learning how the system&apos;s database worked and connecting Excel directly to the local SQL database.&lt;/p&gt;
&lt;p&gt;In July 2025, software development became my main occupation. A lot of the work that led me there happened in finance, logistics and business consulting.&lt;/p&gt;
&lt;h2 id=&quot;connecting-the-spreadsheet-to-its-source&quot;&gt;Connecting the spreadsheet to its source&lt;/h2&gt;
&lt;p&gt;The spreadsheet already existed. The task was to let it read from the system instead of depending on someone to copy the figures across.&lt;/p&gt;
&lt;p&gt;That changed what a discrepancy meant. If a value was wrong in the spreadsheet, we could trace it back to the source instead of wondering whether it had been mistyped during the update.&lt;/p&gt;
&lt;p&gt;It did not remove the need to check the numbers. Errors in the system still needed someone to investigate them. What it removed was the repeated work of extracting and transferring the information.&lt;/p&gt;
&lt;p&gt;I was learning about databases by working with one that people already depended on.&lt;/p&gt;
&lt;h2 id=&quot;then-came-logistics&quot;&gt;Then came logistics&lt;/h2&gt;
&lt;p&gt;In 2015 I became logistics supervisor at the same company, working with inventory, distribution, purchasing and imports.&lt;/p&gt;
&lt;p&gt;I modelled stock availability and built a Power BI dashboard to support purchasing and distribution decisions. A unit in stock is not one thing. It can be available, committed, in transit, in customs or on consignment. Treating all of those as the same number is how you promise a hospital something you cannot deliver.&lt;/p&gt;
&lt;p&gt;The model had to reflect those differences so that purchasing, sales and logistics could work from the same view. Getting the data onto a screen was only part of the job; deciding what it represented mattered just as much.&lt;/p&gt;
&lt;h2 id=&quot;how-duonorth-changed&quot;&gt;How Duonorth changed&lt;/h2&gt;
&lt;p&gt;In 2017 I started Duonorth Studio as a business intelligence and business solutions consultancy. I worked with clients to understand their operations, reconcile data and build dashboards, spreadsheets and automations they could use in their daily work.&lt;/p&gt;
&lt;p&gt;Around 2020, Duonorth expanded into marketing and digital delivery. The work connected business analysis with campaigns, websites, landing pages and software. I handled diagnosis, scope and the relationship with the client, bringing in specialists as the work grew.&lt;/p&gt;
&lt;p&gt;From 2023, I concentrated the business on websites and software. I was both directing the work and implementing it: WordPress sites, themes, plugins, interfaces and integrations in HTML, CSS, JavaScript and PHP.&lt;/p&gt;
&lt;p&gt;In July 2025, software product development became my main occupation.&lt;/p&gt;
&lt;h2 id=&quot;what-carried-over&quot;&gt;What carried over&lt;/h2&gt;
&lt;p&gt;I still ask where the data lives before designing anything on top of it, and what someone needs when they bring me a requirement. Knowing what they will do with the answer helps me design the interface. I also draw on the experience of agreeing on a scope with a client and following it through delivery.&lt;/p&gt;
&lt;h2 id=&quot;what-im-still-learning&quot;&gt;What I&apos;m still learning&lt;/h2&gt;
&lt;p&gt;The current work takes me deeper into relational databases, authentication, security, automated tests and continuous delivery. Earlier experience with dashboards and websites helps, but it does not answer every question that comes up in a software product.&lt;/p&gt;
&lt;p&gt;That experience helps me frame the problem. I still have to test my assumptions about the systems I build and learn from what breaks.&lt;/p&gt;
&lt;p&gt;I wrote more about the learning part in &lt;a href=&quot;https://andremarinho.me/posts/learning-to-code-with-ai&quot;&gt;working with AI without skipping the understanding&lt;/a&gt;. The current work is on &lt;a href=&quot;https://github.com/andre-lmarinho&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>career</category><category>story</category><category>business-intelligence</category></item></channel></rss>