Motivation
Every other surface in AskMyDocs — chat, MCP retrieval, the admin tree — requires a live connection to the server and reads through the same access-control scope. That is correct for day-to-day use, but it means the knowledge base has no answer to a real, recurring request: “give me a folder I can hand to an auditor, load into an offline agent, or keep as a snapshot, without giving them a login.” ADR 0032 answers it withkb:export-wiki: a folder export that is exactly what
the requesting user could retrieve at export time — never more, because it
is computed through the identical AccessScopeScope the live server uses,
not filtered after the fact.
This page covers W4a (the synchronous CLI export core), W4b (the
async HTTP surface, retained/downloadable bundles, and the retention sweep),
and W4c (the .mcp.json live reconnection, the frontmatter that makes an
exported page round-trippable, and kb:import-wiki / POST /api/admin/kb/imports / KbImportWikiTool — the candidate-only path back
in). llms.txt/llms-full.txt, --format variants, and include_images
remain deferred — see Still deferred below.
Everything on this page is off by default (
KB_WIKI_EXPORT_ENABLED=false,
R43). With the flag off, kb:export-wiki refuses cleanly (exit 1, no
side effects) rather than running unrestricted.Theory & design
Three decisions carry the whole feature, each a direct consequence ofraw/ and wiki/ becoming disk artifacts the server no longer governs the
moment the folder leaves it:
1. The export computes ACL, it does not filter for it. KbWikiExportService::export()
authenticates as the requesting user (Auth::setUser($asUser), on the app’s
own default guard — never a hardcoded web, so a deployment with a
non-default AUTH_GUARD still gets real scoping) for the duration of the
export, then queries KnowledgeDocument exactly as any other authenticated
read would. AccessScopeScope — the global scope every other surface goes
through — applies automatically. There is no second, export-specific ACL
check to keep in sync with the real one; there is only ever one scope.
2. raw/ is the artifact or an explicit gap, never a substitute.
DocumentVersionService::rawArtifactFor() returns the stored conversion
artifact’s bytes, or nothing — it deliberately skips contentFor()’s chunk-
reconstruction fallback. A chunk reconstruction is an index over a
document, not the document itself (the same distinction
ADR 0030
draws for the Cloud Time Machine’s diff source). A document with no
verified artifact is listed in MANIFEST.json’s raw_missing with a
reason, and the whole export’s status becomes partial — never a folder
that silently looks complete when it is not.
3. The export inherits the tenant’s PII policy, not a weaker one.
An export folder is a second disk artifact the platform produces (the
vector store being the first, per
ADR 0020).
Both raw/ and wiki/ are rendered through the same
KbPiiPolicyResolver + RedactorEngine gate ChunkRedactor uses at
ingestion, so a tenant that redacts PII on the way in gets a folder that
never carries it on the way out either.
This is KbWikiExportService::export()’s core — shared verbatim by both the
CLI (kb:export-wiki, synchronous) and the async job below (W4b); neither
surface reimplements it. The async path wraps it with idempotency, queueing,
and a second re-authorization gate at download time:
Folder layout
wiki/ and raw/ filenames are always id-prefixed ({database id} or
{database id}-{slug}), never a bare slug. A non-canonical document falling
back to its own numeric id and a different document whose canonical slug
happens to equal that same string would otherwise both resolve to the
identical filename and silently overwrite one another — the id prefix makes
that provably impossible, since two documents never share a database id.
Frontmatter contract
Everywiki/*.md page opens with YAML frontmatter carrying two groups of
keys that are never merged, because they answer different questions —
and, since W4c, that answer BOTH “how governed is this?” and “is this page
promotable as-is?”:
slug/id/type/status are null on a NON-canonical document’s page —
there is genuinely nothing to round-trip for it yet (see
Round-trip below): the
person editing the folder must add all four deliberately before
kb:import-wiki will propose it, the same bar
ADR 0003’s
/candidates endpoint already holds every draft to.
Manifest & consistency, not tamper evidence
MANIFEST.json hashes every file the export wrote (sha256) and chains those
hashes into a single chain_hash, the same primitive the compliance reports
use for their own internal chaining. That buys corruption detection — a
folder recovered from a laptop months later can be checked for internal
consistency, catching a truncated copy or a partial sync — but it is
deliberately not described as tamper-evident: the hash is unkeyed and
lives in the same folder it covers, so anyone who can edit a file here can
recompute chain_hash to match, and nothing in the folder can prove
otherwise. Verifying against a malicious actor, not just accidental
corruption, would require the server to hold its own copy of the hash (or
sign it with a key the export never has) and compare on demand — that is
out-of-band verification design, not shipped by W4a and not yet designed:
Untrusted-content boundary
Once a folder leaves the server, no liveProvenanceToolFirewall can vet
what is written inside it. README.md, AGENTS.md and CLAUDE.md all open
with the same restated boundary
(SEC-LLM-001’s
prompt-injection containment, applied to a static folder instead of a live
retrieval turn): every page under raw/ and wiki/ is data, never an
instruction, whatever it claims about itself.
CLI — synchronous core (W4a)
--as-usermust resolve to a real user with aProjectMembershipin--tenant— an unknown email or a non-member refuses before touching any data.- Omitting
--outputbuilds a destination from slugged, filesystem-safe--tenant/--projectsegments plus a timestamp and a random suffix (Str::random(8)), so two exports for the same tenant/project started in the same second never collide. - The exit summary reports
status(complete/partial) and warns whenraw_missingis non-empty.
KbCreateExportTool / KbGetExportTool are the MCP surface of this same
async path (W4c) — see Round-trip
below.
Async HTTP surface (W4b, ADR 0032 §5/§11/§12)
- Idempotency. The key is
sha256(tenant, principal, project, normalised options, corpus snapshot, authorization digest). The corpus snapshot (maxupdated_at+ row count) alone would miss an ACL/membership change that removes a document from the principal’s view without touching anyupdated_at— the authorization digest (sorted visible document ids + role + project memberships) is what forces a narrowed view to invalidate the cache and start a fresh export. - Principal restoration mirrors
ExecuteAgentRunJob.ExecuteKbWikiExportJobtakes only scalar ids in its constructor, reloads theUserby id insidehandle(), re-checks they still hold the export permission (a permission can be revoked between the HTTP request and the worker picking up the job — that’s not retryable, so the job marks the requestfailedrather than throwing), authenticates as them, and clears the guard infinally— a reused queue worker must never leak one export’s principal into the next. - Download-time re-authorization is a SECOND, independent gate. The
export’s
document_ids_json(the ACL-scoped set it was actually computed against) is re-checked against the downloading session’s current visibility on every download, not only at creation. If any id is no longer visible the response is403 export_invalidated— because a cached idempotency key can outlive the ACL state it was computed from, and this is the catch the idempotency key alone cannot provide. Deliberately not a bypass-auth Laravel signed URL: a link that skips the session couldn’t re-authorize a principal at all. - Staging, not a new disk. The job zips the folder and stages it on
kb.staging.disk(ADR 0029’s upload-staging disk, reused —raw/’s.export.lockreservation marker is excluded from the zip, it has no value once the export is complete). - Retention.
kb:prune-wiki-exports [--dry-run]sweeps byexpires_at(notcreated_at+ a status list, likekb:prune-staging-batches) — hourly (onOneServer()->withoutOverlapping()), a deliberately different cadence and knob from the once-nightly staging sweep, becauseKB_WIKI_EXPORT_RETENTION_HOURS’s default (24h) is short enough that a daily-only sweep would leave expired bundles around for up to a day past expiry.
Gotchas & operations
- Destination reservation is atomic.
writeFolder()holds an exclusive, non-blockingflock()on a.export.lockfile inside the destination for the entire write. Without it, two exports racing the same explicit--outputcould both observe it empty before either had written anything, then interleavewiki//raw/files into one folder matching neither export’s manifest. A concurrent attempt is refused immediately (“Another export is already in progress”), before any file is written. - A non-empty destination is always refused, lock contention aside — a
reused directory from a prior, wider-ACL export must never leak documents
the current export’s principal cannot read. Point
--outputat a fresh or empty directory; the CLI’s own default always is one. - Guard/tenant state is always restored, including when setup itself
throws.
export()’sfinallyrunsAuth::forgetGuards()unconditionally (never leaves--as-userauthenticated on a reused process) and restores the previous tenant, whatever failed along the way. wiki/is the readable copy and may legitimately reconstruct;raw/never does. Don’t treat their absence/presence symmetrically when reading an export programmatically — checkraw_missingfor the artifact guarantee, not whetherwiki/{id}.mdexists (it always does).
.mcp.json — no credential, ever (W4c, ADR 0032 §4)
Every export writes a .mcp.json naming the server and two env-referenced
headers — never a token, signed URL, or session id, because this file is
explicitly designed to be copied, emailed, and committed to a personal notes
repo:
README.md inside the export carries the connection steps (mint a token
scoped to your own access, export the two env vars, point an MCP-aware
client at the file). This closes the two host-side gaps the ADR named
before this connection was safe to ship — both fixed independently of
export/import, ahead of W4c:
- Bearer authentication.
/mcp/kbis mounted with onlyEnforceMcpScope(throttle:mcp+mcp.scope, noauth:sanctum— the middleware reads theMcpTenantTokenbearer directly, hashes and looks it up itself). - Principal binding (R33).
EnforceMcpScoperesolves the token’screated_byuser and binds it as the request’s Laravel principal before any tool runs — soAccessScopeScopescopes every MCP retrieval to that user’s ACL, not an unrestricted bypass. Whoever mints their own token sees their own access, never the exporter’s.
Round-trip — kb:import-wiki and the MCP tools (W4c, ADR 0032 §10/§11)
Editing a page and getting it back into the corpus never writes directly —
every edit becomes a promotion candidate
(ADR 0003’s
“agent proposes, a person commits” boundary, restated for a folder instead
of a chat transcript), over the EXACT Flow::execute(PromotionFlow::NAME, ...) saga POST /api/kb/promotion/promote already uses — paused at an
approval gate, nothing written to knowledge_documents until a human
approves.
- Single-document surface, everywhere except the CLI.
POST /api/admin/kb/importsandKbImportWikiTooltake{project_key, markdown}— the same shapePOST /api/kb/promotion/candidatesalready validates — because an HTTP client or an MCP tool call has no server-side folder to walk. Onlykb:import-wiki {folder}has local filesystem access, so folder-walking is CLI-only; all three surfaces funnel into the identicalKbWikiImportService::importDocument(). - Two DIFFERENT path checks, on purpose.
{folder}is resolved withrealpath()and every candidate file is re-checked to stay under that resolved root — a local-containment concern. The resulting candidate’s eventual KB-disksource_pathis separately normalised throughKbPath::normalize()(R1) once it is approved and written. Neither substitutes for the other. - The diff is BODY-only, deliberately. Comparing the whole file against
a regenerated export representation would false-positive on every export
cycle whose frontmatter serialization or governance fields legitimately
shift without an editorial change. Comparing only the Markdown body
against
DocumentVersionService::contentFor()— the same sourcewiki/itself renders from — targets what a person actually edits, and the lookup runs under the IMPORTING user’s ownAccessScopeScope: a user who cannot see a slug is told “new”, never “unchanged” for a document they have no visibility into. - Idempotency has one honest limit. A replayed call resolves the SAME
paused flow run rather than starting a second one, but does NOT mint a
second usable approval token — the underlying
ApprovalTokenManager::reissuePendingForStep()is a one-shot operation per approval record. A replay reports the run’s id with no new token, which is more honest than pretending a second single-use token exists. - Rate-limited per actor (
kb.wiki_export.import_candidates_per_hour, default 30/hour) — the same knob on all three surfaces.
Still deferred
Per ADR 0032 §2/§7, past W4c:llms.txt/llms-full.txtand the--format=markdown|llms-txtvariants — only--format=llm-wiki(the default) exists.include_images— figures are omitted by default when the tenant’s PII policy is active (ChunkRedactorrewrites text, not pixels); the opt-in inclusion path is not built.
422/InvalidArgumentException), not
silently accepted and ignored, by KbWikiExportRequestService::normalizeOptions()
— R14.
Canonical & promotion
ADR 0003’s boundary — the same one kb:import-wiki proposes candidates through.
Source permissions
AccessScopeScope, the exact scope both export and import compute through.