← back

A private knowledge wiki that compiles into a public portfolio

live automationsecurity

What I built

A pipeline that turns raw material into a private, cited knowledge wiki, and then promotes chosen pages from it into this public site. The raw material is whatever I already have: LLM conversations, code, articles, notes.

The problem is specific. A portfolio that lists what someone built says very little to a security hiring manager; the signal is why a decision was made and what was rejected. Most of that reasoning already happens in conversations with an LLM, and then it evaporates. This system captures those conversations as durable sources, compiles them into decision records and concept explainers, and publishes a curated subset.

The page you are reading was produced by that pipeline, from the conversation in which the pipeline was designed.

flowchart LR
  subgraph private["homelab-wiki (private)"]
    raw["raw/<br/>chat logs ¡ code ¡ notes"]
    wiki["wiki/<br/>concepts ¡ projects ¡ decisions"]
    raw -->|ingest| wiki
  end
  subgraph public["portfolio-site (public)"]
    site["src/content/projects/"]
  end
  wiki -->|publish| site
  site -->|git push| cf["Cloudflare Pages"]

Every arrow is a step that stops for a human before anything becomes canonical.

Why — the decisions

A good part of the work here was deciding what not to build.

Where the knowledge lives

I kept everything as Markdown in Git rather than in a database or a hosted wiki. That makes the diff the unit of review, and it means the pull request is the human-in-the-loop gate. Audit trail and rollback come for free, and CI can gate the merge instead of being bolted on beside it.

I had originally planned an agent-level interrupt as the approval mechanism. The pull request is strictly better, because it is a checkpoint that already exists and that an agent cannot skip.

One repo or two

I considered a single repo with pages filtered by a visibility flag, two separate repos, and a Git submodule arrangement.

The single-repo version is much less to build and needs no promotion step, but it puts raw transcripts in the same repository that serves a public site, so one misconfiguration exposes everything. I rejected submodules outright: they leak the existence and structure of the private content, and they are painful to operate.

I chose two repos. One private, holding raw sources, the wiki, and the agent’s instructions. One public, this one, holding only what I explicitly publish. Promotion is opt-in per page and defaults to private, so the fail-closed state is the safe one. Never “publish everything not explicitly hidden”.

The cost is that it creates a promotion boundary I then had to design, and it splits one review into two with genuinely different questions: an accuracy gate on the private side (“is this correct?”) and a disclosure gate before anything goes public (“is this safe for a stranger to read?”). Two checklists, not one.

What I deliberately left out

The obvious build includes a vector store and a search service: embed every page, retrieve by similarity, let the agent query the corpus. That is the right answer once a wiki reaches hundreds or thousands of pages. A personal one lives in the tens to low hundreds, where an agent can simply read the pages it needs. So the vector store, the search service, and the gateway around them are all omitted.

I wrote the threshold down rather than leaving it implicit: revisit the decision at roughly 150 pages. It is a judgement call and not a measured limit, but an unwritten threshold is one nobody ever checks.

The cost is real and I would rather name it than bury it. Dropping the vector store also dropped semantic similarity checking, which was the automated half of duplicate detection.

Skills now, a state machine later

Each operation is a skill: a prompt the agent follows, not a running service.

A LangGraph compile pipeline was tempting, and honestly it is the more impressive engineering artifact. A checkpointed state machine with a faithfulness-eval gate reads very differently from “I configured some skills”. I deferred it anyway. Building a state machine before knowing what a good page looks like produces a worse state machine, and I did not yet know the target shape. So the first milestone was compile, review, and publish working end to end, with no backend built at all.

The constraint I put on any future version is that it stays invoked, never a daemon. The moment something makes LLM calls on a timer with no human trigger, the review story the whole system rests on stops being true. source on file ¡ cd4af56b61c0

Keeping the agent from inventing things

An agent writing wiki pages will invent concepts. Left alone it produces “mTLS”, “mutual-tls”, and “client-cert-auth” as three separate pages. Call it concept mitosis. It is the biggest risk to a wiki like this.

I considered letting it run free and cleaning up later, and building the full stack: CI, a prose linter, and a vector-similarity check to catch near-duplicates before they land. The first compounds, because by the time sprawl is visible the cross-links are already wrong. The second needs the search service I had just removed.

What I built instead is a closed vocabulary. Concept slugs, tags, and link relations live in YAML files the agent may read but not write. It can propose an addition; only my explicit approval records it. An aliases field collapses synonyms, so the three names above resolve to one page rather than three.

I am clear-eyed about the limit. A script can catch a slug that is not on the list. It cannot see that a proposed concept overlaps one I already have. That check is judgment, which means my approval step is doing real work and I should not rubber-stamp it.

What the LLM is not allowed to do

Anything with exactly one right answer is a script: the catalog, the backlink map, schema validation, the vocabulary checks, the secret scan. An LLM asked to generate an index is slower, costs more, and can hallucinate an entry or silently drop a page. An index that might be wrong is not an index. A script that is wrong has a bug I fix once; an LLM that is wrong is wrong unpredictably, on every run.

My rule for this is don’t make an LLM do a linter’s job, and its corollary, don’t make an LLM do a for-loop’s job. The agent is reserved for the parts that genuinely need reading or writing prose.

Catching the agent where it writes

Ingest is where the LLM writes most. One messy source can produce or edit a dozen pages. So every run happens on its own branch, commits there, and stops. It never merges and it never pushes.

A pre-flight guard refuses to start on a dirty tree or off a non-main branch. That prevents a subtle failure I would otherwise hit: ingest topic A, forget to merge, ingest topic B, and now B is built on A’s unreviewed work with the two diffs entangled. The guard never auto-fixes. It will not stash or force-switch, and it hands the problem back to me.

I chose one branch per run, rather than one per source file or one long-lived branch. Per-run matches the unit I actually review in one sitting. Per-file is too fine, since one source legitimately updates several pages together. A long-lived branch accumulates unrelated work and loses the cheap throw-away, which was the whole point.

What crosses into public

This was the crux of the design. Publishing a project means assembling its full reasoning, and that exists in two places: the messy originals, and the compiled wiki pages. Reading the originals would give the publish step richer material.

I made it read only the compiled wiki. Three reasons, and the third decided it. The wiki pages were already reviewed, with claims cited and unverified markers resolved. The wiki is already deduplicated and cross-linked, so the publish step does not have to redo a merge that a purpose-built step already did. And raw transcripts are the most dangerous material in the system, so a secret sitting in one has no path to this site through publish. That is a data-flow boundary rather than a policy, enforced by what the step can reach instead of by what it promises.

The consequence I accepted: a published page can be no better than the wiki page behind it. If a decision record is thin, this page is thin, and the fix is always to improve the wiki, never to let the publish step improvise. That is the right tension. It makes “keep the wiki good” the single thing I have to maintain, and that is also what makes the wiki useful to me. source on file · bba82d3c21a4

Since private source links cannot survive the crossing, I transform citations rather than dropping them. The reasoning behind each decision is inlined as self-contained prose, which is what you have been reading, and load-bearing claims carry a short content hash of the reviewed note behind them. It is a provenance signal rather than a link: it says the claim traces to a real note, without exposing the note.

Catching leaks anyway

My first design had a homegrown regex secret scanner. That was the wrong call. A handful of hand-written patterns miss cloud provider key formats, JWTs, SSH keys, and high-entropy strings, while still generating false positives, and keeping pace with new credential formats by hand is not realistic. On a security portfolio specifically, naming a maintained tool is also a more credible claim than “I wrote some regexes”.

I chose Gitleaks over TruffleHog. TruffleHog goes deeper: it verifies whether a detected credential is still live by calling the provider’s API. That is precisely what I do not want a publish gate doing. It should never phone a cloud provider; it only needs to block on a match. Gitleaks is fast, MIT-licensed, and a single binary with no dependencies.

One config, two placements: the publish gate, and a pre-commit hook on the private repo that stops a secret from landing in the wiki’s history at all. The hook matters most for raw chat logs, which are the likeliest place a forgotten token hides. A finding is a hard stop, not a warning. source on file · f3df8b636c4f

How it works

Six operations, each a skill. ingest compiles a source into cited pages. ask answers questions from the wiki with citations. lint audits for contradictions, orphans, and stale pages. index regenerates the catalog and backlinks. publish promotes a project here. vocab is the only writer of the vocabulary files.

Three page types. A concept is a reusable explainer, a project is a thing built, and a decision is a choice with its alternatives. Each has required frontmatter and required sections defined by a JSON Schema, and carries a schema version so old pages migrate lazily instead of breaking.

Three folders, three jobs. Structure, vocabulary, and navigation stay separate. Schemas define shape, the taxonomy defines allowed words, and the indexes are generated and never hand-edited.

Ingest, in order. Resolve a topic, the join key tying several sources to one body of work. Create the branch. Check whether pages for that topic already exist, and enrich them rather than spawning parallels. Read the source for durable knowledge rather than transcribing it. Write pages with claim-level citations, propose typed links for confirmation, validate, regenerate the indexes, commit, and stop.

Publish, in order. Resolve one project slug. Gather its cluster by following wikilinks exactly one hop, never recursively, or it drags in half the wiki. Secret-scan the gathered pages, strip private source links, inline the evidence, rewrite as one narrative with the decisions leading, map to the public schema, write the file, and stop. A push is what deploys.

Concretely: this page began as one reference to one project page, and pulled in eleven linked pages of reasoning automatically.

What I’d do differently

The scope oscillated badly before it settled. The design ran from an over-engineered architecture, down to “just Markdown files and a static site generator, no pipeline at all”, and back up to what exists now. The middle version was a fair correction to real overengineering on my part. Starting from “what is the smallest thing that produces a good page” would have reached the same destination faster and with less thrash.

The compile pipeline is still a plan. The state machine that would be the strongest engineering artifact here has not been built. The reason for deferring it holds up, but it does mean what runs today is the minimum viable version.

The page types shipped narrower than designed. I specified five and built three. For a security portfolio the missing one that matters is the threat model, since STRIDE-per-element tables were supposed to be a headline artifact.

The provenance hashes may not survive contact with a reader. I added them for the credibility angle, and flagged at the time that they were optional and a little unusual. If they read as a gimmick, they should go. The inlined evidence does the real work.

The unresolved tension is disclosure risk. Persisting raw transcripts is what makes the reasoning trail possible, and transcripts are also the likeliest place a forgotten secret lives. The two gates and the scan handle it, but the private repo is genuinely sensitive and I treat it that way.

Duplicate detection has no automated half. That is the cost of dropping the vector store, and it is the one that will bite first if the wiki grows.


Drafts here are AI-assisted; the decisions, the review, and the technical claims are mine. Every page passes a schema and vocabulary validator and a secret scan before it reaches this site, and I read each one before it is published.