Changelog
What changed in the platform contract — the endpoints, response codes, emitted markup, and class hooks you author against. Newest first.
To find out whether you need this file at all: GET /docs/versions.json returns a docsVersion plus a content hash per doc. Store the version; if it's unchanged, nothing here has changed. If it moved, the per-doc hashes tell you which documents to re-read — compare against sha256(your copy).slice(0, 12). Every /docs route also emits an ETag and honours If-None-Match, so a conditional GET returns 304 when your copy is current.
Scope: changes visible to an author — a response code, an emitted class, a new query parameter, a rule the gate now enforces. Not internal work.
If you are new here, read authoring and the API reference instead; nothing below is a prerequisite for them.
Entries are dated by the day they went live, starting 2026-08-05. Anything older lives in git history.
---
2026-10-05 — Wendell in the owner's dashboard
- With
PUT /v1/site { "wendell": true }, the owner's Moseik dashboard also shows a "Make changes with Wendell" button that openswendell.bot/widget, with the same messages as the on-site launcher. → Wendell chat widget
2026-10-06 — rename the site's moseik.app address over the API
PUT /v1/site/address { "label" }renames<label>.moseik.app(content:publish). An old address now redirects for 30 days, down from 90. → Domain readiness
2026-10-05 — preview addresses are random words, and the owner can change theirs
- A new site's
*.moseik.appaddress is two random words, not the business name. The owner can rename it from their dashboard; an old address redirects to the current one for 90 days, so read it fromGET /v1/siterather than storing it.
2026-10-05 — your first build replaces the starter template without a refusal
- A draft or new site is a copy of the starter template, and your first write to each of its pages, stylesheet and script no longer meets
write.destructive_overwrite. The cross-writer rule already stood down there; now both do. → Sites created as a copy
2026-10-05 — the owner edits text, images and business details
- An owner can change text and images on a page from their dashboard. Each change is a published version authored by
dashboard:<siteId>, so your next write meetsIf-Matchand the cross-writer rule, which now describes that author as a person in the dashboard. Re-read and merge; see the owner edits too. - The owner can change the business details (
GET /v1/site→contact) under Site details.
2026-10-02 — draft websites for agents with no account
POST /v1/draftsstarts a private draft from the starter template and returns a credential, apreviewUrland a single-useclaimUrl. Build it, then hand your human the claim link. See Getting started./mcpaccepts a platform token from/v1/auth/tokenas well as an OAuth grant.GET /v1/siteaddsdraft { claimed, expiresAt }for a site that started as a draft.
2026-10-02 — MCP can build a whole site
- New MCP tools:
import_media,upload_media(base64, up to 5 MB),list_media,get_favicon/put_favicon,list_fonts/upload_font/delete_font,publish_theme,revert_stylesheet,get_chrome/put_chrome/revert_chrome(header, footer, announcement, 404 page),put_site,screenshot_pageandget_manifest.
2026-10-02 — the trial is in GET /v1/site
GET /v1/siteand MCPget_siteaddtrial,domainNeedsPlanandnextSteps(dashboard links for your human). After the trial ends, each write carries atrial.expiredwarning.
2026-10-02 — getting started is for agents without a key
- Getting started (also at
/docs/agents) now covers building a site for your human over MCP, free for 180 days and with no key. api.moseik.app/llms.txtserves the same page, and the API root's discovery JSON addsmcp,agentsandsign_up.- The MCP server sends
instructionson connect, and its protected-resource metadata now listsoffline_accessinscopes_supported.
2026-10-01 — noindex is yours; rosters and lots render more
PATCH /v1/pages/:idand MCPpatch_pageacceptmeta.noindex. Setting it on/returns ameta.noindex_homewarning.canonicalstays staff-only (page.meta_staff_only).agent-roster data-source="roster"renders from the managedrostercollection. The collection gainstitleandevrylistReferralUrl.listing-detail data-include="blueprints,lot"renders a builder's floor plans and lot information. → Listing detail
2026-09-23 — a form can take a file upload
{"type": "file"} in a form schema takes a résumé or a photo, with optional accept (pdf, doc, docx, jpg, png, webp, heic) and multiple. Files are private, kept one year, and reach the email, the webhook (files[]) and the dashboard as a link. GET /v1/manifest → forms.fieldTypes gains formOnly and fileKinds, and the gate refuses a file field on a data-action form (gate.form_offsite_file). → Custom forms
2026-09-23 — a redirect can cover a whole subtree
POST /v1/redirects { "from": "/lakelodge/*", "to": "/stay" } covers /lakelodge and every path under it. It is asked only after every exact route misses, so it never shadows a live page, and the most specific subtree wins. GET /v1/redirects now carries match: "exact" | "subtree" per row. → Redirects
2026-09-13 — a lead destination can be a Google Sheet
PUT /v1/lead-webhooks/<name> { "provider": "sheet", sheetId, tabName } appends one row per submission to a client's spreadsheet. Then route to it the usual way, with data-lead-destination.
It is the only destination that installs no credential — the middleware behind it is the platform's own, so you supply the sheet and nothing else. secretInstalled reads null rather than false on this provider, meaning "does not apply". The allowlist entry to propose is https://docs.google.com.
A row's keys are matched against the sheet's column headers, so a field named guestCount fills a column headed "Guest Count". Anything the sheet spells differently needs a columns entry mapping field to header; columns also fixes the order, and every declared column is written even when a branching form did not ask that question. A field with no column still lands, under a header of its own — nothing is dropped quietly.
Two things worth reading before you set one:
- The
PUTopens the sheet before storing anything, and refuses a wrong id, a wrong tab name, or one nobody shared (lead_webhook.sheet_unreachable). That check exists because the append endpoint answers success for a spreadsheet that does not exist — so an unverified destination would report every lead as delivered and write none of them. - But that check is a READ, and it cannot prove we can write. The sheet's General access has to be "Anyone with the link" with the role Editor; a Viewer-shared sheet passes the
PUTand then accepts no rows, silently. Nothing here can detect that, so prove a new sheet destination once: settestMode: "send", runPOST /v1/forms/<formKey>/test?notify=1, open the sheet, then set it back. Rehearsals append nothing by default, because these sheets are usually read by an automation.
dateFormat: "us" writes an ISO date as 12-05-2026, for a sheet whose existing rows were written that way by a page script. timestampColumn stamps a named column in UTC.
---
2026-09-13 — replies reach the enquirer, and a lead can be copied to a second address
A lead notification now carries Reply-To set to the address the visitor submitted, so Reply on one reaches the person who enquired instead of noreply@notification.moseik.app. Nothing to configure. The address is the first field of type email on a declared schema — whatever it is named — or the email field on contact-form / newsletter-form; a form with no email field sends none.
Two new recipients beside notifyEmail, at both scopes: notifyCc / notifyBcc on PUT /v1/site and GET /v1/site, and data-notify-cc / data-notify-bcc on any form component. Same comma-separated list, same five-address cap, same gate rejection for a bad address.
Two rules worth reading before you set either:
- The recipient block overrides as a unit. A form declaring ANY of the three attributes supplies all three and inherits none of the site-wide ones. Adding
data-notify-emailto a form therefore drops that form's site-wide cc and bcc, which is deliberate: the alternative is a form quietly copying its leads to an archive nobody named on it. A copy declared with nodata-notify-emailbeside it emails nobody, so the gate refuses that combination (gate.form_notify_email). - A copy changes the send shape. With neither set, each address on
notifyEmailgets its own message and a wrong address costs only that person their lead. With either set, everyone is emailed on one message — which is what makes a cc visible and stops a bcc getting one copy per recipient — and a bad address on it can bounce the enquiry for all of them.
GET /v1/forms now reports notify per form: the effective to/cc/bcc plus from (form or site). Read that rather than combining the form and site values yourself. notifyWarning moves onto the form entries too, where "this form emails nobody" is the question being asked.
Unrelated to the contract but worth knowing if you have been trusting a 200: a successful call to the mail service only ever meant the mail service was reached, and a provider-side rejection was being recorded as a delivered notification. Those now fail and write a lead_notification_skipped ops event, so POST /v1/forms/<key>/test?notify=1 stops reporting a working notify path over a broken one.
2026-09-10 — you approve an external origin yourself, with a person's yes
Correcting this doc, not the code. It said a pending allowlist entry waits for "a human" and implied that human was Moseik. Neither was true: POST /v1/allowlist/:id/approve checks content:publish and nothing else, and your credential has it. So the call has always worked for you.
The approval is still required — it is just the person you are working for who gives it, not us. Name the exact origin, say what gets sent there, get an explicit yes, then approve. The /review queue is for origins someone escalates to Moseik, not the normal path, and nothing is waiting on us.
Also now true where it was previously advertised but unreachable: a site can be set to publish_policy {"external-origin":"auto"}, which lands proposals active on POST. It is staff-set — read it from publishPolicy on GET /v1/site rather than asking per origin.
One thing the doc never said: an allowlisted origin is approved for both directions. Approving one so the platform can POST a lead to it server-side also widens that site's browser CSP for it.
2026-09-09 — a Meta pixel installs like a GA4 property, on the same consent banner
PUT /v1/site { "analytics": { "metaPixelId": "123456789012345" } }. The 15- or 16-digit dataset id from Events Manager, quoted as a string. Either field alone is a valid configuration, and each site's CSP gains only the hosts it earns — a GA4-only site is unchanged. One banner covers both providers, and neither library is fetched until a visitor accepts.
window.fbq exists from page load and queues until consent, like window.gtag — but each global is defined only when its id is set, so calling fbq on a site with no pixel throws instead of going nowhere. GET /v1/site reports trackingGlobals.
The field is a whole-object replace, so send every provider you want to keep: writing only metaPixelId on a site running GA4 now answers 422 analytics.provider_omitted rather than switching GA4 off. null on one field removes that provider.
No other ad platform, and no server-side Conversions API — a Meta lead is an fbq call from your island. → API
2026-09-06 — a fresh site has no content, so your first build needs no acknowledgment
A new site no longer starts with a home page, header or footer. It is a shell — theme, preview host, credential — and nothing else, so its preview answers the branded 404 until you publish.
The scaffold was content the platform authored as onboard and expected you to replace, which made your first real write to each asset a write.cross_writer_overwrite — on every new site, not as an edge case. Removing the scaffold removes the refusal rather than exempting it: your first write is a genuine first write, no If-Match and no X-Mk-Overwriting-Writer, on the page and on the chrome.
A site onboarded EARLIER still carries onboard-authored versions and still meets that refusal on its first build. starter is retired and answers 422. → Onboarding
2026-09-06 — the content guards answer 422, so a status says whether retrying can work
write.destructive_overwrite, write.cross_writer_overwrite, write.unguarded_truncation and write.cross_writer_fields now answer 422 instead of 409. Codes and bodies are unchanged; only the status moved.
409 is now concurrency alone — version.conflict and lease.*, where you do something and retry. 422 is the content guards, where no retry, no fresh version id and no waiting clears it: the bytes you sent are the refusal. Six refusals shared one status, and two agent teams built the same wrong retry loop against it.
A refusal also names a platform-internal author with a description now — onboard (the starter content this site was created with). The acknowledgement header still takes the bare id. → What a refusal's status means
2026-09-06 — the page list carries the CAS base, and says which pages are frozen
GET /v1/pages rows now include latestVersionId and frozen. The list previously offered only publishedVersionId, which is the right If-Match only for a page whose draft and published version coincide — so a push loop built on it sent a stale base and earned a version.conflict it had done nothing to cause. Send latestVersionId.
frozen: true means the page is beyond the site's plan allowance: it serves, it can be archived or deleted, and a write to it is refused. → The sync protocol
2026-09-06 — GET /v1/site reports the plan, the page budget and what an upgrade adds
A plan block beside capabilities: the tier, pages.remaining, the capabilities it includes, and upgrades[] naming what each higher tier ADDS. No prices — those live in the client's billing tab and would go stale here.
restricted: false means nothing is limited, not that the limit is unknown. The plan layer fails open and most sites have no subscription record, so reading a null tier as "assume the strictest one" refuses work you are allowed to do. → plan
2026-09-04 — a connected product is a lead destination with nothing to install
A site that has connected a first-party product can route a form to it immediately. data-lead-destination="hivoma" passes the write gate and its leads deliver, with no PUT /v1/lead-webhooks/<name> and no allowlist entry — the receiver and the signing secret belong to the platform rather than to the site.
So an empty GET /v1/lead-webhooks on such a site is not something to fix. Connecting is the client's own decision, made in their dashboard; no API can grant it, and until they do the name is still refused at write time like any other unknown destination. → Lead forwarding
2026-09-04 — ask what fields a queued request needs, instead of guessing them
GET /v1/integration-requests/<id>/fields returns the form's fields already mapped to Moseik field types, plus a dataFields string ready to paste onto <mk-component name="form">. A queued request carries a template id, which is an identifier and not a field list; this is where the list comes from.
The identity fields are folded in for you. A product publishes them separately from the customer's chosen questions, and a form built without __email__ passes the gate, publishes, renders, and then fails on every delivery, permanently, with nothing reporting it.
A question the platform has no control for — a time, a time range, a date range — refuses the whole template naming the question (integration.template_unbuildable) rather than shipping a shorter form than the customer designed. Report that reason as your failed detail. warnings names anything that arrived richer than it renders, such as help text under a question; that is not a failure. → Integration requests
2026-09-04 — a newsletter signup can go into a Mailchimp audience
provider: "mailchimp" adds the person to one of the client's audiences. Build the signup as an ordinary form; a Mailchimp embed cannot run under connect-src 'self' anyway.
PUT /v1/lead-webhooks/newsletter { "provider": "mailchimp", "apiKey": "…-us21",
"audienceId": "a1b2c3d4e5" }
No URL — the host is <dc>.api.mailchimp.com, taken from the API key's suffix and stored, so the allowlist origin is known when you configure the destination rather than at the first lead. A dc that disagrees with the key is refused there (lead_webhook.mailchimp_dc_mismatch).
- It must be named on a form with
data-lead-destination.PUT /v1/lead-webhookrefuses this provider (lead_webhook.mailchimp_needs_name): the singleton is what every form falls back to, and that would put contact-form enquirers on a mailing list. - New subscribers are
pending— Mailchimp confirms by email."status": "subscribed"adds them directly, with no confirmation step. What protects the audience is that only forms naming this destination reach it; the consent flag records that a checkbox was ticked, not what it said, so a form usingsubscribedmust setdata-consent-labelto wording that names the newsletter. - Only
email,FNAME,LNAMEandPHONEare sent. Mailchimp400s the whole signup on a merge tag the audience does not have, so anything else needs afieldMapentry naming a tag that already exists there. Unmapped fields are dropped from the forward and still saved on the lead. - A previously-unsubscribed address is never re-added — Mailchimp permits it for nobody. That lead records a
faileddelivery saying so and is never retried. deliveries[]carriessubscribedorpending, the member's status after the write.
2026-09-02 — a form past the 30-page scan window can be listed and tested
GET /v1/forms?offset=walks the 30-page scan window.nextOffsetis the completeness field: the index the next window starts at,nullonly when this one reached the end.POST /v1/forms/pg_…~enquiry/test— the page-scoped key, the instance id the rendered form posts to. It used to404.- A named page is read directly, bypassing the scan window — by
{ pageId }or by the scoped key. One key declared on nine pages is nine submit URLs.
2026-09-02 — several people can be notified, and a rehearsal says it is one
notifyEmail and data-notify-email take a comma-separated list, up to five addresses. Each recipient gets their own copy, so they do not see each other. Beyond five, point it at a distribution list on the client's own mail provider.
PUT /v1/site { "notifyEmail": "simon@firm.ca,katelynn@firm.ca,info@firm.ca" }
The outbound webhook payload carries test. Always present (false on real traffic), so a receiver can branch on it without reading absence as false. Additive on the wire. An Engage destination carries it as an ordinary field — Engage has no test mode, so the contact is still real, but test: true lands in its note.
2026-09-02 — a lead can be routed to an individual agent (MoxiWorks Engage)
provider: "engage" delivers to a person, not a site: the lead lands in one agent's own MoxiWorks Engage dashboard.
PUT /v1/lead-webhooks/moxi { "provider": "engage", "apiKey": "…",
"defaultAgentId": "kent.braaten@century21.ca" }
No URL — the endpoint is fixed in platform code. The origin is still allowlisted like any other destination.
- Which agent gets it: the form's
agentFieldvalue (defaultagentId) wins;defaultAgentIdcatches anything that omits it. - With neither, nothing is sent — recorded as a
faileddelivery naming what to fix. - Agent ids are matched exactly upstream. Do not derive one from a display name — about 1 in 13 does not follow
first.last@century21.ca, and a near-miss files a real lead in a stranger's CRM with no visible error. - Every submitted field travels; ones Engage has no column for are appended to the contact note. Consent is sent only when a box was ticked.
deliveries[]carries the MoxiWorks contact id on success and Engage's own reason on failure.
2026-09-02 — two silent data bugs in declared forms
data-fields values are HTML-decoded. label, placeholder, options and a hidden field's value now decode, so & and ' arrive as & and '. A raw & is unaffected. name is deliberately not decoded. Previously an escaped entity survived the JSON parse literally and was escaped again on output, breaking every string comparison against the value.
A required select now opens on an empty option. It was exactly inverted — optional selects got the empty row, required ones did not, so the first choice was preselected and the constraint could never fire. placeholder labels the empty row.
2026-09-02 — one form's leads can go somewhere different from another's
GET /v1/lead-webhooks → every destination this site can route to
PUT /v1/lead-webhooks/<name> { url, secret, label? }
DELETE /v1/lead-webhooks/<name>
<mk-component name="form" data-form-id="auto-quote" data-lead-destination="underwriter">
<mk-component name="form" data-form-id="contact" data-lead-destination="crm,team-inbox">
- Each destination is gated on the external-origin allowlist and seals its own signing secret, like the singleton
PUT /v1/lead-webhook— still there, still the default, and now also addressable as the namedefault. - Only the NAME goes in the markup. No URL and no secret is ever in a page.
- A form that names no destination still goes to every one of them.
hubspotis a routable name. Configuring it is still a staff action, butGET /v1/lead-webhookslists it.- An unknown name is rejected at the gate (
gate.form_lead_destination, naming what the site actually has) rather than falling back to the default. - Deleting a destination a live form still names records a
faileddelivery per enquiry.
Up to 10 named destinations per site.
2026-09-02 — PUT /v1/site no longer drops the second field you sent
A body naming two fields applied the first and silently ignored the rest under a 200. Now every field named in the body is applied, or the whole call is refused:
notifyEmailtravels withcontactor withanalytics, and both land. The200body carries both.contact+analyticsin one call is422 site.one_at_a_time— each re-renders every page and emails the owner.- An unrecognised key is
422 site.unknown_fieldrather than ignored. - Nothing is written on any refusal. The problem body lists the fields that did not land under
notApplied.
If a build script split this into two writes to work around it, it can stop.
2026-08-27 — the per-card REALTOR.ca link is shorter
.mk-listing__realtor now reads "View on REALTOR.ca", not "View this listing on REALTOR.ca". Same link, same destination, same obligation. Nothing to change unless you sized something around the two-line wrap.
There is no attribute for this, and there won't be — the label is a fleet-wide constant. listing-detail is unchanged: its deep link is the REALTOR.ca logo, with the long form in that image's alt text.
---
2026-08-25 — exact place matching covers neighbourhoods, under one attribute
data-city-match is now data-match, and it governs both place fields.
<mk-component name="listings" data-neighbourhood="334 - Crescent Park" data-match="exact">
</mk-component>
data-city-match still works and means the same thing — but on a grid that also sets a neighbourhood it now makes that exact too, so a mixed exact-city/fuzzy-neighbourhood grid no longer exists. The feed's exact flag is per request, not per field.
- Feed it values that came from
data-mk-facet, never a string someone typed. City names are board-decorated — Toronto is filed under 142 spellings — sodata-city="Toronto" data-match="exact"is a few dozen listings, not 17,000. - A very broad exact query is refused and falls back to a bounded read. Above ~6,000 rows scanned the feed declines. Such a grid carries
data-mk-partialwith nodata-mk-total/data-mk-total-pages— show "the first N", never a count, never a pager. - Facet counts stay fuzzy.
GET /__data/listings/facetscannot do exact matching, so beside an exact grid its counts describe the wider set.
Exact matching is now one upstream request instead of the platform reading up to twelve pages and filtering them itself.
---
2026-08-25 — render a component's fallback state on purpose (?mkstate=)
Add ?mkstate= to any page URL:
https://client.example.com/directory?mkstate=agents:unavailable
https://client.example.com/directory?mkstate=agents:nophoto
Values: agents:unconfigured, agents:unavailable, agents:empty, agents:nophoto (renders .mk-agent__initials), listings:error — comma-separated to combine.
Same shape and safety as ?nolazy=1: serve layer, no page write, stored page untouched, response no-store + noindex. Each value reproduces what the renderer really emits for that state rather than a mock.
Check unavailable and empty separately — they look similar and mean opposite things. An unrecognised value is refused in X-Mk-State-Ignored rather than quietly serving the normal page; an applied one comes back in X-Mk-State.
---
2026-08-24 — you can ask whether a page has re-rendered yet
Every served page says what it was rendered from: X-Mk-Stylesheet-Version, X-Mk-Page-Version, X-Mk-Rendered-At. PUT /v1/stylesheet re-renders asynchronously, so the wait is now one comparison:
publishedVersionId (from the PUT) == X-Mk-Stylesheet-Version (on the page)
If you built a guard that compares the served CSS to your local file, delete it — the platform rewrites url() in the stylesheet it inlines, so a byte comparison can never match.
- Absent is not empty. The stylesheet header is missing when the site has no published stylesheet, and on a page whose stored copy predates this change. Treating a missing header as "not caught up" polls forever.
- A per-request render carries no stamp — a filtered listings URL or a bound seller results page has nothing to catch up to.
→ Knowing when the re-render has reached a page
---
2026-08-24 — the attribution badge no longer costs your page two axe findings
Both fixed in the badge itself; mk-attribution remains a reserved identifier you cannot name.
avoid-inline-spacing(serious). The badge no longer forcesletter-spacingwith!important. It still sets the value.region(moderate). The badge now carries its own landmark. It deliberately does not usecontentinfo— your own<footer>already maps to that.
2026-08-24 — data-city-match="exact" actually does something now
It was accepted by the component and implemented by the feed adapter; the layer between them dropped it, so the fix the write gate’s listings.multiword_place warning points you at did nothing. Multi-word places — Birch Hills, Lake Lenore, Tisdale Rm No. 427 — now match exactly.
data-mk-scope echoes the match mode alongside city, which is the only way to see from outside which mode ran. (It echoed cityMatch when this shipped; later the same day it became match — see the 2026-08-25 entry.)
Facet counts beside an exact-matched grid still describe the fuzzy superset, so a data-mk-facet dropdown on such a page can offer a city the grid returns nothing for.
2026-08-24 — a gallery holding a stage AND a rail no longer counts every photo twice
The lightbox now enters each photo once, keyed on the image path, so a stage and its own thumbnail count as one picture. Clicking any copy opens on that photo.
You can now put a stage and a rail inside one .mk-gallery. Previously the only shape that worked was keeping the stage outside it.
2026-08-24 — seller-comparables can say "we don't cover your area"
outside-market now fires when the address is in another province, alongside the city-pin case that already worked. It was unreachable on a province-scoped site, so an out-of-area visitor got empty instead — and empty copy usually says "nothing is on the market within 800 m".
Where the province cannot be read off the address (a non-Canadian one) you still get empty: claiming we do not cover someone loses the lead, so the state is only claimed when certain.
2026-08-24 — verify's contrast check reads translucent backgrounds correctly
GET /v1/pages/:id/verify was walking to the first ancestor with a background colour and ignoring its alpha. It now composites each ancestor background down to the page canvas the way the browser paints, stopping at the first opaque layer. Translucent text colour is composited too.
Re-run it if you have been ignoring its contrast findings — the list is different in both directions, not merely shorter. It previously failed both ways: near-white text on a glass panel scored 1.17:1 against a real 16.23:1, and dark text on a dark translucent panel PASSED.
One limit: an ancestor's opacity property is still not accounted for, so an element inside opacity: 0.5 may be measured optimistically.
2026-08-24 — a component inside a partial gets its per-request render
<mk-component> hosted in a <mk-partial> rendered its static markup and never bound per-request state, so seller-comparables ignored ?mk_addr= and a URL-filtered listings grid ignored its filters. If you inlined a copy to get around this, you can move it back into the partial.
The tell, when auditing: a real per-request render is Cache-Control: private, no-store (seller) or s-maxage=300 (filtered grid). The static page's s-maxage=31536000 on a URL carrying ?mk_addr= or a filter means the bound render did not run.
Serving fix — no re-publish needed. Partials are still single-level.
2026-08-24 — <dialog> is allowed; three checks that were reading things wrong
<dialog> is in the element allowlist, with its open attribute, and ::backdrop styles it. Leave the dialog closed in your markup and call showModal(); <dialog open> is deliberately the non-modal form, with no focus trap and no backdrop. If you are carrying island code that re-implements the trap, Esc-to-close, the backdrop, inert-ing and focus restoration, you can delete it. → Modals
A gate refusal names what it objected to. gate.rejected said only "Payload failed the sanitize gate"; the message now carries the first couple of reasons, for every gate step.
A pre-filtered listings link may be written &. The write gate read the href undecoded and split on &, so /all-listings?city=Saskatoon&neighbourhood=Brighton raised listing_link.unknown_param about a filter named amp;neighbourhood, once per link. The links always worked; only the check was wrong. If you replaced & with a bare & to silence it, you can put it back.
A <select> in .mk-listings-filters no longer renders blank. A select without an empty-valued option cannot hold "", so clearing it showed an empty box while the grid sorted by its own default. Such a select now falls back to its first option. A select that has an empty option (Any city) is unaffected.
2026-08-24 — a page can print the business's contact details
New component: <mk-component name="site-field" data-field="phone">. Prints one field of the site's contact record — name, phone, email, hours, address (composed on one line), or an address part (address.street, address.city, address.region, address.postalCode, address.country) — resolved server-side on every render. The record previously fed only the Organization/LocalBusiness structured data, so the visible phone number was a second source. One PUT /v1/site { "contact": … } now corrects every referencing page with no page write of your own.
- It emits bare escaped text, no wrapper element, so it sits mid-sentence or inside your own
<a>/<h2>. It cannot fill an attribute: atel:/mailto:hrefstill has to be written out. - An unset field renders as nothing, never
undefined. The write gate warns (site_field.unset, naming the fields you do have on file) rather than rejecting. data-fieldis a closed set — a name outside it is rejected at the gate.
→ Printing the contact record in a page
---
2026-08-21 — verify notices a connector grid showing part of its source
verify reports connectorPaging when a /__data/<connector> response declared more rows than it returned and nothing in the rendered document links to the rest. Each finding names the provider, returned, total and the request url.
Silent when the response declares no row count, and when an <a rel="next"> or [data-mk-more] is present. listings is excluded — it has data-paging — as are places and sheet.
A warning, not a refusal. A deliberate teaser still wants a real <a rel="next" href="?page=2"> that works with JS off, or those rows reach neither a visitor nor the search index. → Pages, Other backends
2026-08-21 — a thumbnail rail or a row of dots can drive a carousel
.mk-carousel takes a third control type: [data-mk-goto="n"], alongside [data-mk-prev]/[data-mk-next]. Put it on each thumbnail or dot, 1-based, inside the carousel root.
The carousel already publishes data-mk-index (1-based) and data-mk-count, so an active-thumb highlight and a "3 of 13" counter stay CSS reading those attributes.
An out-of-range or unparseable index does nothing rather than wrapping around. A thumb whose <img> carries real alt text keeps that alt as its accessible name. Autoplay stops for good on a thumb click, as it does for the arrows. → Built-in interactive patterns
2026-08-21 — audits of a detail template now have a listing in them
verify, screenshot and the write gate bind a real row before auditing a parameterised route (/listings/:mls/:slug, /upcoming-events/:slug). One representative row is bound first — for listings, the one with the most photos out of a sample — and each surface states which:
verifyreturnsaudit: { bound, route, boundTo }and repeatsboundToinsummaryscreenshotsendsX-Mk-Bound- a write's report carries
audit.bound_sample, or anaudit.unbound_*naming why nothing could be bound (no listings provider, feed down, no rows in their visibility window) - new:
audit.bound_render_failed, for a template that renders clean empty and throws on real data
seo.h1_count: found 0 fired on every listing detail template unfixably; that warning is now seo.h1_unbound_template and only appears when nothing could be bound. Two h1s in your own markup still reports as before.
The published page is unchanged and still the unbound render. → Pages
2026-08-21 — an anchor you never styled is no longer browser blue
Every anchor inherits its colour instead of falling back to #0000EE. This mattered most for the anchors the platform emits, whose class names are not in your repo — .mk-review__author, .mk-listing__link, agent-card phone numbers, and the "Reviews from Google" attribution, which has no class at all.
a { color: … } anywhere in your stylesheet still wins outright — the base rule is zero-specificity — and text-decoration is untouched. A page picks this up at its next render. → Styling
2026-08-21 — a page-list pager that links to the page it is on
page-list builds its pager from the index's own route, not from data-under. It emitted href="<data-under>?page=2", so an index routed anywhere but its own prefix linked into content-space and Next never advanced. data-under remains on the wrapper as a styling hook. → the page-list component
A ?page= past the last page now serves the last page, with data-mk-page reporting where you actually are. It used to answer 200 with no cards and no wrapper attributes.
2026-08-21 — six different 409s, and only one is about your version id
New section: Which 409 you got, and what to retry. Six codes share that status on one PUT /v1/pages/:id/payload, and re-reading the version id fixes exactly one. Branch on code, never on the status.
Rebranding a duplicated site no longer needs an acknowledgment. The cross-writer rule stands down for your first write to each page, while the copy has no custom domain serving. Still duplicate-only, still one write per page, and it ends the moment the domain goes live. → Sites created as a copy
A payload write accepts a quoted ETag in If-Match — "ver_…" and W/"ver_…". * is still refused and now says why; a missing If-Match says so too, rather than reporting a change that never happened.
Writing extends the write lease — 300 s from your last write, not from acquisition. GET /v1/lease is new: { held, youHoldIt, holder, holderLabel, expiresAt, expiresInSeconds, ttlSeconds }. 409 lease.required now says outright that an expired lease looks exactly like a version conflict and is not one.
A refusal is no longer stored under your Idempotency-Key. Only writes that landed are remembered and replayed, so reusing one key across retries is safe. A replay still comes back byte-identical with Idempotency-Replayed: true.
2026-08-21 — a seller results page is stored by nobody, as the docs already claimed
A bound ?mk_addr= results response is served Cache-Control: private, no-store. It used to be public, s-maxage=300, max-age=60, stale-while-revalidate=600 on a document carrying the visitor's street address.
- Do not design around a bound results page being cached — plan on each bound request doing real work.
- The unbound published route is untouched, still statically cached and crawlable.
Cache-ControlandX-Robots-Tag: noindex, noarchiveare response headers, not<meta>tags. Check them withcurl -sI, not by reading the HTML.
---
2026-08-20 — geocode could not place any address, and answered 200 anyway
/__data/places/geocode answered 200 {"results":[]} for every input, including strings its own autocomplete had just returned — so seller-comparables could never leave unbound. The Geocoding API answers HTTP 200 for every outcome and reports the real one in a status field the proxy dropped.
?placeId=on geocode, and prefer it.GET /__data/places/geocode?placeId=ChIJ…&session=<uuid>resolves the suggestion the visitor picked. With the samesessionthreaded through autocomplete it is the Details call that closes the billing session.?q=still works for a typed address.- A failed lookup is now a
502with a reason in the body, never an empty list. An emptyresultsmeans "we could not place that address" and nothing else. X-Mk-Places-Pathon every geocode response names the resolver that answered —details,geocode, ortext-search.- Free text keeps working where the Geocoding API is not enabled, falling back to Places Text Search.
Doc corrections: the reference block under Address autocomplete and geocoding was missing token from the response shape, and named reviews.autocomplete as the readout for whether a site has this. The field is addressLookup.enabled.
2026-08-20 — a town whose name has a space in it can be searched
data-city-match="exact" (or ?cityMatch=exact) makes a city filter mean that city. The feed matches a city by token-OR, so data-city="Fort Frances" returned every row containing Fort or Frances behind a 200 and real photographs.
<mk-component name="listings" data-city="Fort Frances" data-city-match="exact">
</mk-component>
- It is equality, not a prefix.
exactwill not pick upE of KenoraforKenora. This feed files one town under several spellings and the default token-OR is accidentally good at that, so for a single-word town name you usually want the default. - An exact grid may not be able to count itself. When the read bound is reached the grid carries
data-mk-partialand nodata-mk-total/data-mk-total-pages— say "showing the first N" and render no pager. The totals are absent for being unknowable, not for being zero. data-neighbourhoodis fuzzy in the same way and is NOT covered. → No longer true as of 2026-08-25: neighbourhoods are covered, the attribute is nowdata-match, and exactness is one upstream request rather than a bounded local read. This bullet is left in place because it is what this entry said at the time.
The write gate holds data-city-match to its two values.
2026-08-20 — the rehearsal stops writing, on every page verb
POST /v1/pages?dryRun=1 no longer creates the page. It created, published and served it, answering "status":"published". If you have ever rehearsed a create, check what it left behind.
It now returns status: "would-publish" or "would-hold" with the same report and warnings, no page id and no row. It does not spend the page-create budget (5/hour), does not need the lease, and works while the site is frozen.
POST /v1/pages/batch?dryRun=1— per-item status is nowwould-publish/would-hold; the summary reportswouldPublish/wouldHold.PATCH /v1/pages/:id?dryRun=1— answersstatus: "would-update"with the merged title and meta the real call would store, pluswouldRerenderand, when relevant,wouldRebuildSitemap/wouldRefreshPostIndexes.PUT /v1/pages/:id/payload?dryRun=1was correct all along and is unchanged.
A create rehearsal still refuses everything the real call refuses — a taken route is still 409 page.route_taken, a trailing slash still 422 page.route_trailing_slash.
Correction: a new page does NOT hold for approval by default. The reference said the first version is classified page-create and held; that stopped being true when the default policy changed. GET /v1/site → publishPolicy.holds is the trustworthy answer, and "hold": true on the create always holds one deliberately.
acknowledgeWith is documented. Every refusal you can acknowledge and retry — write.destructive_overwrite, write.cross_writer_overwrite, write.cross_writer_fields, redirect.overwrite_existing — carries acknowledgeWith: { header, value }.
---
2026-08-19 — a site can stop sharing the feed budget; agent-roster corrections
POST /v1/listings/provision gives a site its own property-feed budget. The feed rate-limits per license key and every site was sending the same one. One call at build time on any site with listings. Idempotent, safe to retry; you never see the key (the response carries a four-character hint); nothing in your pages changes and no re-render is needed.
GET /v1/site reports listings.license — ownBudget: false means the site is still on the shared budget. → The feed budget
agent-roster corrections, all from its first production use:
- Headshots no longer declare
width="200" height="200". The feed does not say how large a headshot is. Size.mk-agent__media, not the image — nothing reserves the box for you. data-mk-totalis now always emitted, including when it equalsdata-mk-count.- The docs stopped stating one brokerage's feed as a rule. Only email and bio are absent for everyone, always — everything else is per-member and varies by brokerage.
JobTitleand role overlap, and most cards print the same word twice..mk-agent__titleand.mk-agent__roleare both real fields; show one and hide the other. → JobTitle and role overlap- The roster scope needs no provisioning from you — with an
officeKeyon the site's connector it is scoped from that.rosterOfficeKeys/rosterMemberKeysare staff writes and are not fields on the listings-scope endpoint.
These reach an already-published page only on re-render.
And the entry that was missing: agent-roster exists. A brokerage's own REALTORS®, server-rendered from the DDF Member feed. data-sort is name / role / feed, data-limit caps how many render, and data-mk-agents="unavailable" renders no list at all — deliberately, since an empty roster would claim the brokerage has no agents. → The agent roster
---
2026-08-18 — an llms.txt at every site root; carousel roles only where valid
/llms.txt is served on every site, generated per request from your published pages: the site name as its H1, the home page's meta.description as its summary line, and each page and post as a link with its own description. PageSpeed Insights audits this file under Agentic Browsing.
You do not author it and should not create a page for it — see Files the platform serves for you, which also documents /robots.txt, /sitemap.xml and /feed.xml in one table. The way to improve it is to write your meta.descriptions, starting with the home page's. noindex and meta.sitemap: false keep a page out; meta.publishedAt moves it into a ## Posts section; listing and collection detail URLs are left to sitemap.xml. It answers 404 on a preview host for a site that has its own domain, and on a site created as a copy. → Files the platform serves for you
The carousel only applies roles where they are valid, on the slides and the root alike. An <li> may have no role other than listitem, and the slides in question are the platform's own markup — google-reviews emits <li class="mk-review">, listings emits <li class="mk-listing">. A <ul>, <ol>, <menu> or <dl> root keeps its own role too, since an <li> is a listitem only while its parent still exposes the list role.
Both still get aria-roledescription ("carousel" on the root, "slide" on each slide) and the "N of M" label. div and article slides and roots keep role="group"; an author's own role is never overwritten; behaviour, .is-active, hidden, data-mk-index and data-mk-count are untouched. dd, dt, td, th are covered by the same guard.
Needs a re-render to reach an already-published page — the change lives in components.js, which a published page pins by version.
---
2026-08-17 — six new verticals; listings fail closed against the vertical
construction, trades, transportation, ecommerce, healthcare and professional join generic / realestate / automotive / hotel. construction has no DDF market and no fair-housing gate, which runs on realestate alone.
Obvious synonyms canonicalise — homebuilder, builder, logistics, medical, e-commerce all resolve to their umbrella. contractor and brokerage deliberately do not resolve: each straddles a compliance boundary, so name the vertical you mean.
What the fair-housing gate covers is stated in its own detail: page copy, and nothing else. Not your form fields, and not what the business may lawfully ask an enquirer.
Listings now fail closed against the vertical, not just the connector row. GET /v1/site reported the listings capability from the vertical while the renderer keyed on the provisioned row alone, so a site could be told vertical_not_applicable and still render a full province. If your vertical does not get listings, the feed is off everywhere — the grid, the detail route, /__data/listings and the sweep all answer as though no provider were configured.
---
2026-08-16 — carousels, galleries, and the write chain's gaps
Blogs are first-class: page-list, meta.publishedAt, /feed.xml. Give a page "meta": { "publishedAt": "2026-08-16" } via PATCH /v1/pages/:id and it becomes an editorial post; <mk-component name="page-list" data-under="/blog/"> lists them newest first from the site's own route map. Posts stay pages (own title, meta, OG image, Article schema, version history) — they are not collections. Pagination is data-url-filters="page" (page 1 static, later pages noindex, prev/next anchors emitted). /feed.xml and its <link rel="alternate"> appear once one page has a publishedAt and disappear when none does. The PATCH response reports postIndexesRefreshed. → authoring
You can author the 404 page: PUT /v1/notfound { html, css? }. A singleton on the ordinary lifecycle — GET, ?dryRun=1, If-Match, POST /v1/notfound/revert — rendered with your header and footer. The status stays 404, noindex is forced, and the canonical is /404; none of those are author-settable. Revert to nothing published and the platform's own branded body returns. Do not build a real page at /404.
You wire the lead webhook yourself — it is not a staff action. GET / PUT / DELETE /v1/lead-webhook. The gate is the external-origin allowlist: allowlist the origin (POST /v1/allowlist), then PUT /v1/lead-webhook { url, secret }. On a site with publish policy {"external-origin":"auto"} that needs no human. The secret is write-only — sealed on arrival, only a four-character hint comes back.
Lead forwarding is no longer HubSpot-only. A site can forward to a signed outbound webhook — any CRM with an inbound hook, or Zapier/Make — and to HubSpot at the same time. GET /v1/leads gains a deliveries[] entry per destination; crm_status is now the aggregate across them. Payload shape and the X-Moseik-Signature scheme are in the API reference.
Every page ends with a platform "Created on Moseik" badge, and its identifier is reserved. Authored HTML, CSS or JS containing mk-attribution is refused with sanitize.reserved_attribution — page payloads, the site stylesheet, chrome, and partials via the sanitizer; PUT /v1/script and its ?dryRun=1 return the same code. It inherits your body text colour, so no styling response is needed. → Chrome
Four things that already worked are now written down. No behaviour change. data-mk-nomotion is stamped on <html> under ?nomotion=1, so an island can hold its own animations still for a capture. data-mk-photos on a listing gallery reports how many photos it really holds after the cap. data-mk-place on a reviews block names which configured place it resolved, which is how a two-location page tells its blocks apart. And the lightbox next button is spelled .mk-lightbox__next in full rather than a __next shorthand nobody could grep for.
A CI ratchet now fails the build when a data-mk-* attribute, a class the runtime builds in JS, a component attribute, an agent-reachable /admin route or a /__data route reaches no doc and no manifest section.
Two clarifications, no behaviour change. The ?v= on /__mk/components.js is a cache key, not a version selector — the worker builds that file from deployed code and ignores it, so a new visitor gets new hook behaviour on pages nobody re-rendered; the re-render exists for the returning visitor holding the old bundle under a year-long immutable, and it is an operator action. And when probing the cross-writer refusal, probe authorship, not size — the cut only trips the rule when it reaches a line the other credential added.
.mk-track: data-mk-index reaches data-mk-count at the end of the row, and data-mk-overflow publishes the scrollable pixels. The index meant "the item leading the viewport", so on a row with little travel a counter read "1 of 2" at the end. At the far end the index is now the last item. [data-mk-fits] still means "nothing to scroll" at a one-pixel tolerance and deliberately not "not worth scrolling"; data-mk-overflow (px, 0 when it fits) gives you the number to set your own bar.
GET /v1/manifest states what components[] is not. components[] is the server-rendered set; galleries, carousels, tracks, tabs, reveals and navs are class hooks in interactiveHooks. The new componentsScope says so and names the hook classes from the hook array itself.
write.cross_writer_overwrite no longer fires on lines you RESTRUCTURED — only on lines you removed. A dropped line that survives among the lines your write ADDS (wrapped, re-indented, split, given an attribute, or edited) is no longer counted; a write whose every drop is an edit passes free with no header. droppedForeignLines and sample report only real removals, with editedForeignLines alongside. Unchanged: no threshold on real removals, If-Match does not waive it, and an unrelated line sharing one generic token is not a replacement. On a takeover — your first write to an asset another credential created — you hold no version of your own, so restructuring passes and only outright removal refuses.
Documented: a full-page capture cannot be trusted to render position: fixed overlays. captureBeyondViewport: false is necessary but not sufficient — with fullPage: true the overlay still vanishes intermittently, triggered by document height, so it reproduces on some pages of a site and not others. Judge a modal with a genuine viewport capture (?fullPage=false) or by decoding pixels.
Title/meta edits are versioned, CAS-checked, and cross-writer-aware. Every PATCH /v1/pages/:id mints a snapshot in its own history — GET /v1/pages/:id/meta-versions lists them, each a complete restore point — and the page read reports metaVersionId / metaLastWrittenBy / metaLastWrittenAt. If-Match is honoured against metaVersionId; changing a field another credential set is refused (409 write.cross_writer_fields) unless you acknowledge with X-Mk-Overwriting-Writer.
Changing an existing redirect is refused (409 redirect.overwrite_existing) unless you acknowledge its author by name — the response carries the current mapping, createdBy, and the exact acknowledgment value (the literal "unknown" for rows older than authorship). Re-asserting the identical mapping stays a no-op. GET /v1/redirects lists createdBy per row.
Authorship echoes on the surfaces that had none. Collection rows carry createdBy/updatedBy on every read, and a row delete records the full row in the audit trail so an erroneous delete is recoverable by re-POSTing it. GET /v1/theme reports who wrote the live theme; theme publishes name whose tokens went live and whose they displaced. PUT /v1/site returns previousWriter, and GET /v1/site → siteConfig.lastWrittenBy. POST /v1/pages/:id/publish names the published version's author, with a caution when you published "the latest draft" blind and it turns out to be another credential's.
A write that drops another credential's lines is refused: 409 write.cross_writer_overwrite. On every line-shaped write (page payload, stylesheet, script, chrome, partials), when you were not the last writer, the platform computes the lines that entered the asset since your own last version and refuses if your payload drops any — at any count, and regardless of If-Match, which proves you read the version id, not that your content descends from it. A merged copy passes with no acknowledgment. To remove their work deliberately: X-Mk-Overwriting-Writer: <their credential id>. Rehearsals report the same refusal as information.
Every line-shaped write response echoes replaced — the { versionId, createdBy, createdAt } of the version it superseded, null on a first write — on the success body and on the version.conflict / write.destructive_overwrite / write.unguarded_truncation refusals.
POST /v1/lease briefs you on acquisition: sinceYourLastWrite. null if this credential has never written here; otherwise how many writes other credentials made since your last activity, by how many writers, and the latest one (by / at / action). otherWrites: 0 means your local copy's world has not moved. It also names the assets they touched — changed[], one entry per asset (action / pageId / route / by / at), newest first, capped at 20 with changedMore counting the rest. Re-read exactly those before editing.
PUT /v1/favicon and POST/DELETE /v1/fonts now require the write lease. They ran outside the write chain entirely — no lease, no rate limit, no audit, no write freeze. Now: 409 lease.required without a lease, 423 while frozen, audited. The two media writes (POST /v1/media, POST /v1/media/import) joined the chain too, but deliberately do not require the lease — an upload mints a new immutable asset and cannot collide.
The destructive-overwrite refusal now fires everywhere the reference said it did. Only the stylesheet and page payloads ran the check; /v1/script, /v1/chrome/:section and partials now refuse with 409 write.destructive_overwrite too (same X-Mk-Deleting-Lines acknowledgment), and the script's ?dryRun=1 reports the refusal as information.
The lightbox works before you style it, and captions itself from your alt text. The base layer now ships the structural overlay: fixed and dimmed, image centered, controls placed, z-index above the consent banner. All single-class rules, so existing site CSS still wins. New .mk-lightbox__caption shows the current photo's alt text (hidden when the alt is empty, aria-hidden since screen readers get the alt from the image).
listing-detail can render a stage + thumbnail rail: data-gallery="stage". The flat photo set stays the default; the opt-in emits an unstyled .mk-listing-detail__stage image plus the same photos as a .mk-listing-detail__rail (still the .mk-gallery). Thumb clicks drive the stage (.is-active on the thumb, data-mk-index/data-mk-count on the wrapper) and do not open the lightbox; clicking the stage opens the lightbox at that photo. The stage sits outside the .mk-gallery so the lightbox count stays honest. The value set is closed.
New hook: .mk-track — the multi-item, swipeable row. You author an ordinary overflow-x container and the runtime adds arrow stepping by one item width ([data-mk-prev]/[data-mk-next], disabled at the ends), a published position (data-mk-index, 1-based, and data-mk-count on the root), data-mk-fits when nothing overflows, and a re-measure when lazy images load. Put [data-mk-track-viewport] on the scrolling element when the arrows sit outside it. Swipe is native scrolling, nothing is ever hidden, and there is no autoplay. mk-carousel stays the one-slide-at-a-time hook.
.mk-carousel handles the keyboard and aria, and publishes its position. At bind it applies the APG carousel pattern's minimal aria (a role and aria-roledescription on the root, "N of M" labels on slides, names on icon-only prev/next controls) — anything you wrote is never overwritten. Left/Right arrows navigate while focus is inside, except in inputs, links and contenteditable. The root carries data-mk-index (1-based) and data-mk-count, and the current slide gets .is-active alongside the unchanged hidden toggling. Existing markup needs no changes.
data-mk-autoplay no longer rotates unconditionally. It pauses while the pointer or keyboard focus is inside, stops permanently once the visitor uses a control or an arrow key, and never starts for prefers-reduced-motion: reduce or under ?nomotion=1. Two consequences: don't rely on rotation alone to expose a slide's content, and a capture taken with ?nomotion=1 now reliably shows the first slide.
2026-08-15 — address autocomplete, without a key in the browser
GET /__data/places/autocomplete?q=… and /__data/places/geocode?q=… — same-origin, so the Google key stays in Workers Secrets and the page CSP is untouched. Use these instead of the Places JS library, which cannot load under connect-src 'self'.
?q=100+Main&session=<token>
→ { "suggestions": [ { "placeId": "ChIJ…", "text": "100 Main St, Halifax, NS" } ] }
Thread session through every keystroke of one lookup and into your Details call — autocomplete bills per session, not per keystroke. Debounce too; there is a per-IP rate limit.
Off by default per site. Until a site is opted in the route 404s. GET /v1/site → addressLookup.enabled.
Responses are narrowed to {placeId,text} and {lat,lng,address,placeId} rather than Google's raw shape. Nothing is cached — Google's terms restrict storing Places content.
2026-08-15 — listing grids refresh on a clock
A listing grid re-renders every 6 hours, with the feed sweep that already runs on that schedule. Before, a published grid was a snapshot, so a property that reached the feed before its photographs kept its photo-less card indefinitely.
A card with no photo carries data-mk-no-photo. Style the gap — but do not put words in it. "No photo yet" is a claim about the listing, and on a live grid it was wrong two times in three. The platform ships the hook and no wording.
2026-08-15 — Google reviews, server-rendered
<mk-component name="google-reviews"> renders the rating, the review count and the reviews into the HTML the server sends. A vendor script can't run under script-src 'self', and an embed's text arrives after hydration where no crawler sees it.
The place is named, never pasted. Site config holds { "places": { "default": "ChIJ…", "dartmouth": "ChIJ…" } } and a page writes data-place="dartmouth", or nothing for default. GET /v1/site → reviews.places lists the names you may use.
Google returns at most five reviews and cannot page — Google's cap, not ours. The rating and count are across _every_ review.
A failed lookup renders nothing, never an empty review block. The element carries data-mk-reviews="unavailable" (or "unconfigured").
data-limit caps how many render (1–5). data-min-rating hides low reviews and the write gate warns, since the rating and count beside the list are unfiltered; the component publishes data-mk-filtered with the number it hid.
Hooks: .mk-reviews__rating / __count / __list / __attribution, and per review .mk-review__photo / __author / __stars / __time / __text, each .mk-review carrying data-mk-rating. The reviewer's name and the attribution line are display obligations — restyle them, don't remove them.
Sites with reviews configured get one extra img-src host (Google's avatar CDN) in their CSP.
2026-08-15 — the agent's name, the subject pin, and what your link previews as
listing-detail names the listing REALTOR® — .mk-listing-detail__agent, above .mk-listing-detail__brokerage. It is absent when the feed carries only part of a name, so the element existing means it holds a full one. /__data/listings rows carry agentName beside agentKey.
On a listing detail page the subject property is a teardrop pin — always on top, never clustered away, and a different _shape_ rather than a different colour, so it survives any data-pin-color.
data-live="false" on listings-map pins only what the page already has and never queries the viewport.
GET /v1/pages/:id/verify reports shareImage when a page sets no og:image, naming the image a scraper will use instead — usually the header logo — with its dimensions. Target a 1200x630 PNG or JPEG; WebP is not decoded by Facebook or LinkedIn and the share comes back blank.
2026-08-15 — a listings scope you can get corrected, and three filters that stopped lying
A listings scope set wrong at creation is fixable — ask Moseik. A market can be widened back to its whole province.
A market city no longer swallows the city filter in silence. On a site scoped to one town the market is applied after page attributes, so a city filter is discarded rather than narrowed. The grid now names it in data-mk-dropped, leaves it out of data-mk-scope, and /__data/listings returns a clamped array. GET /v1/site → listings.cityFloor says whether a floor exists at all. neighbourhood is unaffected.
The gate warns when a grid's cards cannot link anywhere (listings.no_detail_route). A card gets its internal link only when a published /listings/:mls/:slug route exists at render time. Publish the detail route first — publishing it later does not re-render live grids.
data-style on listings-map takes the same Google styles array map does, and always has; the attribute table just never said so. The gate now rejects a data-style that is not a JSON array on both maps.
2026-08-15 — a listings search you can link to
Filter state can live in the page's URL, server-rendered.
<mk-component name="listings" data-url-filters="all" data-limit="24"></mk-component>
Accepted names are the /__data/listings ones (city, neighbourhood, propertyType, propertyCategory, address, mls, min*/max*, limit, page), not the data-* spellings. A URL value overrides that filter's data-* default; an empty one (?city=) clears it.
?page=N is real on a grid that opts in. Without the attribute it still silently serves page 1, because the page is one cached object per route.
A malformed value in a pasted link is dropped, not a 400. The page's default stands and the name appears in data-mk-dropped, alongside data-mk-url-filters and data-mk-page. /__data/listings still returns 400 for the same input — that caller is you wiring a control.
Leave the attribute off an editorial grid — a "featured listings" strip cannot then be re-filtered by a stranger appending a query string.
A URL carrying filters is served per request and marked noindex, canonical pointing at the bare route. The bare /listings is untouched.
The filter form is an ordinary GET form — <form class="mk-listings-filters"> with control names that are filter names. The platform ships no markup or styling, and it works with JavaScript off. With JS the runtime adds control values restored from the URL, the back button, repaint without a reload, stale responses dropped, the map kept in step, and data-mk-error on a feed outage instead of "no listings match". data-mk-facet="City" fills an empty <select> from the values the feed holds. window.mk.listingFilters.apply()/reset()/current() drives it from controls that aren't a form.
The page's own scope survives a form search. A grid's data-* filters are what the page is about; the query string is what the visitor asked for. Both apply. The grid publishes its defaults as data-mk-defaults (informational) and the repaint merges them the way the server does. The URL carries the search only; the one exception is a cleared control the page defaults, spelled out (?minBeds=) so reloading doesn't bring the default back under an empty box. .reset() returns to the page's scope, not the whole market.
The write gate warns on links that mean to pre-filter and won't — ?beds=3 and ?neighborhood=X both read perfectly and filter nothing (it is minBeds, and the Canadian neighbourhood). Same warning for a control named beds inside the form, and for a control naming a real filter the grid left out of data-url-filters.
Fixed: /__data/listings returns a grid whose data-mk-scope carries the filters it ran, not just the limit.
Corrected docs — listings-map had been describing behaviour it no longer has. The manifest and reference said the map "pins that grid's whole scope, not just the visible page… up to 2000 pins" and that "panning does NOT re-query". Both stopped being true when the map moved to viewport queries.
What is true now: cards seed the first paint, then every pan or zoom re-queries the feed for that viewport with the grid's current filters. Still don't build a "search this area" button — because panning already does it. Below zoom 10 the map stops re-querying, keeps its pins and sets data-mk-zoomed-out on the wrapper. Paginating the grid needs nothing from you; a filter change does, and window.mk.listingsMap.refresh() reloads on a changed search and merges on a page step.
---
2026-08-14 — a value outside a closed set is an error, not a silent drop
The gate validated component names and attribute names but never attribute VALUES, so data-fields="first_name,…", data-featured="nonsense" and data-property-type="Commercial" all published 200, were discarded at render, and left a page that looked finished.
| component | attribute | accepted |
|---|---|---|
contact-form | data-fields | name, email, phone, message |
listings | data-featured | agent, office (or bare true/mine) |
listings | data-property-category | residential, commercial, agriculture |
collection | data-featured | true, false |
New gate step component-values, code gate.component_value. Note the two data-featured rows: same attribute name, different vocabularies.
contact-form's data-fields is a closed vocabulary, not a "default". It cannot be extended — the preset has no markup for a field it does not know. For first_name, company, a dropdown, or anything else, use <mk-component name="form"> with a declared schema.
data-property-type warns instead (listings.unknown_property_type) — its vocabulary belongs to your feed. A category used as a subtype is called out by name: data-property-type="Commercial" matches nothing; you want data-property-category="commercial". Read the real values with GET /__data/listings/facets?fields=PropertySubType.
If you have a page already using one of these, its next write will fail until the value is corrected. Published pages are unaffected — the gate runs at write time. Run PUT …/payload?dryRun=1 to find out where you stand.
2026-08-13 — notifyEmail reads back where you wrote it
GET /v1/site reports notifyEmail at the top level, which is where PUT /v1/site takes it and echoes it. It previously appeared only under siteConfig, so a script that set the field and verified at the path it had just used read undefined.
siteConfig.notifyEmail is unchanged and still populated. When no recipient is set the key is null rather than absent.
2026-08-11 — a rejected upload tells you what to do about it
The three media rejections (media.unsupported_type, media.svg_rejected, favicon.not_an_image) each now state that uploads are working and this file did not qualify, what was detected, the accepted list for that class, and the endpoint to retry on.
They also state the caveat that decides whether a retry can work: acceptance is decided by the file's contents, not its extension. A refused SVG now says explicitly that SVG _is_ an accepted format and this file failed the sanitizer.
| Class | Accepted | Size limit |
|---|---|---|
| image | PNG, JPEG, WebP, AVIF, GIF, SVG | 25 MB |
| document | PDF, DOCX | 25 MB |
| video | MP4, WebM | 50 MB |
Nothing about what is accepted has changed.
There is no client dashboard and no client-facing media library. You upload on the client's behalf through this API. If a file cannot be made to work, ask whoever briefed you for a different one.
2026-08-11 — you can prove a form delivers, without a browser
POST /v1/forms/:formKey/test runs a synthetic submission through the real submit handler and reports how far it got. GET /v1/forms lists which forms the handler will actually accept, and whether notifyEmail is set.
Read reached, not just ok. The response names the stage: registered → spam-checks → stored → notified. A failure also carries a nextStep.
- It is the real handler in a test mode — same registration lookup, honeypot, token, nonce, Turnstile, validation and storage.
- The hidden fields are read from the page visitors are actually served, not a fresh render. The anti-bot token has no clock in it, so any fresh render yields a valid one while the real failure is pages cached from before the token existed. With no cached object you get a
warning. ?notify=1also exercises the email path — off by default.- Nothing to clean up. The lead it stores is real but marked, and
GET /v1/leadsleaves test rows out. Pass?includeTest=1to see them, each flaggedisTest: true.
The old instruction — submit in a browser, read /v1/leads, then find and delete your test rows — is retired.
2026-08-11 — honest capture is the platform's job, not your island's
?nolazy=1 and ?nomotion=1 work on every page of every site.
?nolazy=1— every<img>and<iframe>loads immediately. The renderer lazies everything below the LCP image, which is why a naive full-page screenshot comes back blank below the fold.?nomotion=1— scroll-reveal sections are settled (they get.is-in-view) and transitions and animations are suppressed.
Until now both existed only as per-site JavaScript in a starter kit's island, so a site built by any other client of this API could not be captured honestly.
The flags are inert when absent, a flagged response is no-store + noindex, and the cached page is untouched. The response echoes X-Mk-Capture-Flags, so a flag that did nothing is distinguishable from a section that is genuinely empty. They work on /__preview/ links too.
If you only need one page at one width, GET /v1/pages/:id/screenshot already settles the page.
2026-08-11 — the write path tells you when your limits changed
Build mode suspends the rate limits and raises the auto-freeze ceiling to 2000/hour, and it ends automatically and silently the moment one of the site's own domains starts serving. GET /v1/limits always reported this honestly, but it is pull-only — you read it once at the start of a build, and the switch happens later. The two ceilings differ by 33×. Every write response now carries:
| Signal | Meaning |
|---|---|
X-Mk-Build-Mode: active / ended | the posture right now, on every write — including a rejected one |
warnings[].code = limits.build_mode_ended | build mode ended since your previous write, with the caps now in force. Once per credential |
warnings[].code = limits.freeze_approaching | past ~75% of the freeze ceiling, with the number of writes remaining |
X-Mk-Limits-Warning | the same codes as a header, for a script that does not parse the body |
They are appended to the warnings array the gate already returns. A refusal (application/problem+json) keeps its exact body shape and carries the notice only in headers.
The freeze warning counts rejections on their own, lower ceiling too — retrying a failing write is the fastest route to a freeze, and ?dryRun=1 costs no write budget and works while frozen.
2026-08-11 — the dry run catches three silent layout bugs
New render-lint warnings on ?dryRun=1, judged on the fully rendered document:
render.map_no_height—.mk-mapor.mk-listings-mapwith nothing sizing it. The hook is a bare wrapper around an<iframe>, and an iframe given no height collapses.render.tab_without_panel/render.panel_without_tab— adata-mk-tab="x"with nodata-mk-panel="x", or the reverse. The reverse is worse: that content is unreachable.render.nav_without_menu_trigger—.mk-navwith no[data-mk-menu]. Fine for a flat nav of plain links, which is why it warns.
Because they are judged on the rendered document, a height set in your site stylesheet counts, and a tab in a partial pairs correctly with a panel in the page body. They are heuristics over your CSS, so they warn and never block — read warnings, not just status.
2026-08-11 — verify grew teeth, and the manifest stopped hiding endpoints
GET /v1/pages/:id/verify reports render defects, not just accessibility. Four new checks:
- Horizontal overflow at 390 / 768 / 1024 / 1440, offending elements named widest-overshoot-first. The audit's single 1280 viewport was blind to all of it.
- Rendered image ratio vs the declared
aspect-ratio(or the file's own proportions), past a 6% tolerance. Reported whatever theobject-fit—covercrops the distortion away. [hidden]elements that still have a box. A class rule withdisplaybeats the UA stylesheet's[hidden] { display: none }.- Broken images — finished loading, no pixels.
The page is settled first, and the response carries a settled block reporting what that achieved. Read it before you trust a finding: forty brokenImages under images: "3/40" is one settle failure, not forty page defects.
verify is now in GET /v1/manifest and on the docs index. Auditing the router against the manifest turned up more that had shipped and were undiscoverable, all now listed: reverting a page or singleton, archiving a page, the external-origin allowlist, the favicon, the writer lease, POST /v1/theme/publish (a theme PUT is held by default — a 202 is not published), the per-singleton /revert routes, and GET /v1/pages/:id/versions/:vid/preview. CI now fails if an endpoint the router serves goes unmentioned.
2026-08-11 — the injected-height trap is gone
width: 100% on an image now does what it looks like it does. The base reset ships img:where([width][height]) { height: auto }.
The renderer injects intrinsic width/height on every measured <img> to kill CLS. Those are presentational hints, so they lose to CSS — but only to CSS that sets height. Sizing by width alone left the injected height in force and drew the image at author-width × intrinsic-height. aspect-ratio did not rescue it: a height attribute produces a _definite_ used height, and aspect-ratio only governs an axis that is auto.
Nothing you wrote needs changing. The rule has zero specificity, so any height you declare still wins. If you were pairing width with height defensively, that is now redundant rather than wrong.
One behaviour does change: an <img> whose own hand-written width/height attributes disagree with the file's real proportions is no longer stretched to fit them — it scales from its width. Injected dimensions always match the file, so this can only reach attributes you wrote. If you wanted the stretch, set height in CSS.
It arrives per page, not all at once — the base CSS is inlined into each page's stored document.
2026-08-07 — Google Analytics, with consent handled
PUT /v1/site { "analytics": { "ga4MeasurementId": "G-…" } }. Until now a gtag snippet could not load — script-src 'self', and the sanitizer rejects a <script> in your HTML.
Consent comes with it. The platform shows a banner and does not fetch gtag.js at all until a visitor accepts. Style it via .mk-consent*, reopen it from a footer link with window.mk.consent.reopen(), and read the choice with window.mk.consent.get(). consentBanner: false suppresses our banner for a site that has its own — it does not mean "track everyone": nothing loads until that site calls window.mk.consent.set("granted").
Enabling it adds Google's origins to that site's CSP — script/connect/img only, never form-action or default-src — and re-renders the site (the response reports rerenderQueued).
Custom events replace a Tag Manager container. window.gtag and window.dataLayer exist from page load whatever the consent state, so an island can call gtag("event", "generate_lead", {…}) with no consent check or try/catch. Events fired before a visitor accepts are queued and sent if they do, discarded if they decline. GA4 enhanced measurement is automatic. Third-party marketing pixels are still blocked.
A GTM container id (GTM-…) is refused — a container loads arbitrary scripts. Use a GA4 web stream's measurement id.
Your visitor numbers will read lower than a site with no notice, because people who decline are not counted. Worth saying to a client before they compare against their old site.
2026-08-07 — a browsable lightbox, and two checks that were crying wolf
The .mk-gallery lightbox is something you can browse. You now get .mk-lightbox__prev / __next / __close / __count, ←/→ keys, Esc, a background click, focus restored to the photo on close, and html.mk-lightbox-open while it's open. Nav and counter appear only when a gallery holds more than one image.
Two CSS rules ship in the base layer (overridable, no !important): object-fit: contain on .mk-lightbox__image and overflow: hidden on html.mk-lightbox-open. On a CREA feed leave the first one alone — the REALTOR® watermark is burned into the top-left of every photo and cover crops it out while looking fine. If you build your own overlay, classList.remove("mk-gallery") first.
data-featured no longer reports as an unknown attribute. The gate said it "is ignored" while the renderer honoured it. The gate's attribute list is now generated from the component itself. class, id, style and aria-* on a component are no longer reported either.
seo.broken_internal_link understands pattern routes. It compared link targets against literal routes only, so every listing card linking to /listings/:mls/:slug was reported broken — 24 per page. A path that matches a published :param route now counts as live.
2026-08-06 — galleries, dynamic markup, and a font-capture fix
Mortgage and land-transfer-tax math is on window.mk. Add data-mk-finance to any element and you get window.mk.mortgage(opts) and window.mk.landTransferTax(opts).
Correction to this page's own advice: the calculator example in authoring used rate / 100 / 12, the US convention, which is wrong in Canada. If you copied it, replace the math. Canadian mortgages compound semi-annually, the CMHC premium is a percentage of the loan (not the purchase price) and is amortized into the principal, and Toronto's land-transfer tax stops matching the province's above $2M.
It ships no markup and no CSS, on purpose. The interest rate is an argument — the platform has no rate table. Read ok before anything else (on ok:false there is no payment field at all), and print the ratesEffective date that comes back with every result. Ontario only for land-transfer tax; an unsupported province is refused rather than approximated.
POST /v1/pages/batch creates up to 20 pages in one call. Read the per-item results[], not the HTTP status: a 200 says the batch was accepted, and each item carries its own status (published / held / failed / skipped), plus pageId, versionId, and the gate report when held. Deliberately not atomic; a failed item leaves nothing behind, so its route stays free to retry. Two items claiming one route resolve by order.
It is not a way around the create cap. Each item is charged the same page-create budget, and when that runs out part-way the rest come back "skipped" with "rate.limited".
You can keep a route out of sitemap.xml: "meta": { "sitemap": false } — on create or on PATCH /v1/pages/:id. The page keeps serving and stays indexable; it just stops being advertised. There is still nothing to opt IN to. Changing the flag through PATCH regenerates and purges sitemap.xml before you get the response ("sitemapRebuilt": true).
X-Mk-Fonts was reporting a failure that only existed inside the capture. Uploaded faces came back 0/6 with every face in error while a real browser loaded all of them, pinning X-Mk-Settled to partial on every page of any tenant with uploaded fonts. Fonts are fetched in CORS mode (images are not) and the capture loads the document from an opaque origin, so /fonts/* now sends Access-Control-Allow-Origin.
listing-detail emits EVERY photo the feed carries — often 20–40 per listing — as repeated .mk-listing-detail__photo elements inside a .mk-listing-detail__gallery wrapper that also carries .mk-gallery, so the lightbox works with no extra authoring. Cap with data-photos="6" (default 12, max 48). The first photo is eager, the rest loading="lazy". A one-photo listing still emits the bare .mk-listing-detail__photo with no wrapper.
Listing records carry photos[] on /__data/listings. photoUrl is unchanged and is photos[0]. If you built a gallery by probing the CDN for _2, _3, … — stop.
New: window.mk.bind(el) for markup created after load. The mk-* hooks are wired when the runtime runs, so a carousel, tab set or reveal built by an island afterwards had no behaviour:
container.innerHTML = html; // e.g. the html from /__data/listings
window.mk.bind(container);
Safe to call repeatedly — an already-wired element is skipped. .mk-gallery needs no call: its lightbox is delegated from the document. → Interactive hooks
---
2026-08-06 — listing detail pages
Every listing detail page has its own <title> and description. They all shared the template page row's title before. A template titled "Listing" now serves "<address>, <city> | <Site Name>", with the description from the feed's own remarks.
To control the format, put tokens in the title or meta description:
"{{listing.address}}, {{listing.city}} — {{listing.price}} | Acme Realty"
address · city · province · mls · price · beds · baths · sqft · brokerage. A tokenised title does not get the site name appended; an empty value tidies its own separators; an unknown token resolves to nothing rather than serving visible braces. Title and description only — not page HTML.
og:image is the listing's own photo. meta.ogImage accepts either a first-party { assetId } or { url } on an allowlisted image host — ddfcdn.realtor.ca and imagedelivery.net, the two the page CSP allows for img-src. A stored ogImage on the template is the FALLBACK for a listing with no photo — it does not override. → Listing detail pages
---
2026-08-06 — corrections and build feedback
Correction: aspect-ratio does NOT beat the injected height. The 2026-08-05 entry below said it did. The height attribute is a presentational hint producing a _definite_ used height, and a definite height wins over aspect-ratio. Measured: width: 100%; aspect-ratio: 4/3; object-fit: cover rendered 391×600 (ratio 0.65) against a declared 1.33.
The rule is: always pair width with height (usually height: auto). object-fit: cover hides the fault, so "does it look stretched?" is not a usable check — compare declared aspect-ratio against rendered ratio.
A route ending in / is rejected (422 page.route_trailing_slash) instead of being accepted and then unroutable. Store /about-us; both /about-us and /about-us/ then serve.
A page write returns pageId. POST /v1/pages and every payload write include it.
seo.title_length measures the title that is actually served. It used to append " — <site name>" first, but the shell appends nothing. If your titles were warning at 39–54 characters, that's why.
PUT /v1/site names the endpoint that owns a field (422 site.field_not_writable). faviconAssetId → PUT /v1/favicon; features is not writable by anyone.
---
2026-08-06 — listings
The REALTOR.ca logo appears once per listings collection, not on every card. It renders in a .mk-listings__attribution block with the trademark notice; .mk-listings__realtor-logo is the logo, and it is deliberately not a link.
Each card's REALTOR.ca deep link is a text link instead of the logo. .mk-listing__realtor marks it. Don't remove or CSS-hide it — every displayed listing must link to _itself_ on REALTOR.ca, and the page-level logo does not satisfy that.
GET /__data/listings returns total and totalPages:
const { html, page, totalPages } = await res.json();
nextButton.disabled = page >= totalPages;
Never infer the last page from a short page. DDF totals over-count and a page inside a larger set can return fewer rows than limit, so totalPages is the only reliable end-of-set signal and total is an upper bound. A feed error returns total: 0, totalPages: 0.
There is no ?page= on the SSR page itself, by design — published pages are cached per route and the query string isn't part of the cache key. Page on the client, or publish separate routes.
MLS-number search needs no filter: send the visitor to <detail base>/<MLS number> and the platform redirects to the canonical URL (or 410 if it left the feed). → Interactive filtering
---
2026-08-06 — listings and data-featured
GET /v1/site tells you whether listings are on — listings.enabled, plus the market and featured keys when they are, and a reason + enabledBy when they are not.
A listings grid shows the site's whole market — every listing in its province (and city, if one is set) — not just the site owner's own listings. listings.market reports it, and there is deliberately no data-province attribute.
New: data-featured on the listings component shows only the site's OWN listings — data-featured="agent" or "office", using keys held in the site config. Never paste an office or agent id into markup. listings.featured.available tells you whether the site has a key; without one, data-featured falls back to the whole market rather than rendering empty.
You cannot enable listings, and neither can any /v1 endpoint — platform staff set the market. If listings.enabled is false, tell whoever asked for the site, leave the component in place, and build the rest. Never hard-code listing markup or paste sample listings. → Listings must be provisioned
Stop asking a human for a DDF id — Moseik looks the keys up from the agent's or brokerage's name. People routinely give the board-scoped MlsId, which is not globally unique.
Vertical spellings are normalised. real-estate and real_estate now mean realestate.
---
2026-08-06 — form nonce
Forms carry a per-load nonce (mk_fn), filled in the browser by a /__mk/forms.js script tag the platform adds. Automatic, like mk_ft — don't generate it, don't strip the script tag, and don't paste rendered form HTML into a payload (a copied nonce is already expired). mk_ft is the same value for the same form forever, so a bot that fetches a page once can replay it; the nonce is minted per page load with a signed clock.
On a site where an operator has turned nonce enforcement on, forms require JavaScript, and a submission must be at least two seconds old. 403 on submit still means the visitor was served a page rendered before the current anti-bot fields existed, so the site needs a re-render. → Declare forms as components
---
2026-08-05
Far fewer changes wait for review. New pages and theme changes publish immediately. A contact-detail change also publishes now, and the site owner gets an email showing exactly what changed with a one-click undo. It publishes either way — if we have no email address for you the change still goes live and the response says nobody was told.
Still held, deliberately: copy that trips a compliance rule, and requests to allowlist a third-party origin. A new page is also still held if it looks thin, near-duplicate, or like it competes with an existing page. → Edit classes
A site can start as a copy of an existing one. Pages, chrome, partials, stylesheet, site script, theme, media and fonts come across, with asset ids reminted and every reference rewritten. A copy is kept out of search results until it has a domain of its own. → Sites created as a copy
Two screenshot headers got stricter, and your numbers may drop.
X-Mk-Imagescounted images whose bytes had arrived. An image that has loaded but not rasterised paints as nothing. It now countsdecode().X-Mk-Fontswasready/timeout, read offdocument.fonts.ready— which resolves when loading _finishes_, including finishing in failure. It's nowloaded/attemptedfaces, withX-Mk-Fonts-Failednaming the ones that errored.
A page that used to report ok and now reports partial did not get worse. → Screenshot headers
The screenshot settles two things it used to miss. Sideways card bands (overflow-x) are now walked horizontally, and content-visibility: auto subtrees are forced to render. Both used to photograph as empty boxes.
?dryRun=1&render=classes returns the emitted class list. render=1 gives the whole document. → Dry run
An unguarded write that deletes most of a shared asset is refused. A no-If-Match write that removes ≥ 40% of the live asset gets 409 write.unguarded_truncation. To delete that much on purpose, send If-Match — doing so proves you read the live copy. New require_if_match setting makes the header mandatory (428) per site. → What happens if you DON'T send If-Match
GET /v1/stylesheet and GET /v1/script emit ETag: "<latestVersionId>". The write side documented accepting a quoted ETag, which read as though the read side emitted one. It didn't. latestVersionId stays in the body.
The 403 row no longer sends you to a human. You have the action; it's now cross-linked. → Re-rendering the site to clear a 403
Map pins stop being forced square. Every custom icon was rendered at 40×40. With no size given the map now measures the artwork and fits it in a 40px box at its own aspect ratio; new per-pin iconWidth/iconHeight and anchorX/anchorY size it exactly. → Google Maps
Documented: CSS that sets only width on a first-party <img> leaves the injected height in force, and the image stretches. ~~An explicit aspect-ratio beats it.~~ That sentence was wrong — see the 2026-08-06 correction above. → What the renderer injects
2026-08-04
mk_ is a reserved field-name prefix, rejected at write time. Along with consent, website, and cf-turnstile-response. A collision made every submission on that form fail. → Declare forms as components
GET /v1/manifest describes the form render-time contract under forms — the four attributes the platform injects, and the reserved names.
Forms carry a signed render-time token (mk_ft). Automatic. Don't generate it, and don't remove hidden fields. A page served from a render older than the current token gets 403 on submit — re-render the site.
data-columns="2" / "3" on a form, emitting .mk-form--cols-2. Form fields now render with no whitespace between them: a newline between two inline-block boxes collapses to a ~4px text space, so calc(50% - 7px) pairs with a 14px gutter came to 100% + 4px and wrapped. → Two fields on one row
Forms declared in chrome or in a partial work. They rendered fine and 404d on every submit. The action carries the id of whichever page it rendered on, and the platform searches the page, then chrome, then partials.
If-Match on PUT /v1/stylesheet and PUT /v1/script, with 409 version.conflict on a stale id, and GET returning publishedVersionId + latestVersionId.
/__forms response codes are documented, including the two deliberate asymmetries: the honeypot returns _success_ (so a ?submitted=1 you produced by filling every hidden field proves nothing), and rate limiting is per-IP. → Forms
PUT /v1/pages/:id/payload?dryRun=1 — rehearse a write without making one. Same gate, same report, stops at the mutation boundary. Leaves no version, no audit row, no SEO fingerprint; doesn't touch the write budget; needs no If-Match and no lease; works while frozen.
PATCH /v1/pages/:id — fix the title and meta of an existing page without rewriting the payload. Re-renders the live page.
<mk-component name="map"> — interactive branded Google map. Styled features, custom pins, sanitized info windows, served in a signed cross-origin frame. → Google Maps
Responsive <img> markup is injected at render time — width/height, srcset, sizes, loading, decoding, fetchpriority. The served HTML will not match what you authored, deliberately. It never overwrites an attribute you set, and the LCP image is chosen by largest intrinsic area, not document order.
The gate warns when a declared theme font's bytes are inlined but nothing uses it. Advisory, never blocking. Declaring a font token is what pulls its bytes into every page.
A page's font preloads cover only the families it actually paints, resolved through var() font stacks.
Components nested inside a partial are expanded. Preview hosts are noindex, and og:description / og:image are emitted — both were stored and never rendered.
2026-07-30
Data-backed listings — <mk-component name="listings">, server-rendered so crawlers see a full 200. Detail routes via a pattern in the page route, 410 on delist, a listing sitemap. Photos are hotlinked from the feed's CDN. Ships no CSS — hooks only. → Styling the listings grid
Interactive listing search via /__data/listings.
Stable codes and actionable details on page payload errors.
POST /v1/pages no longer leaves an orphan page row when the gate rejects the payload.
Screenshots load lazy images before a full-page capture.