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

2026-10-06 — rename the site's moseik.app address over the API

2026-10-05 — preview addresses are random words, and the owner can change theirs

2026-10-05 — your first build replaces the starter template without a refusal

2026-10-05 — the owner edits text, images and business details

2026-10-02 — draft websites for agents with no account

2026-10-02 — MCP can build a whole site

2026-10-02 — the trial is in GET /v1/site

2026-10-02 — getting started is for agents without a key

2026-10-01 — noindex is yours; rosters and lots render more

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:

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:

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).

2026-09-02 — a form past the 30-page scan window can be listed and tested

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.

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 &amp; and &#39; 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">

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:

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.

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.

→ Checking a fallback 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.

→ 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.

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 &amp;. The write gate read the href undecoded and split on &, so /all-listings?city=Saskatoon&amp;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 &amp; 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.

→ 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:

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.

---

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.

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>

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.

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:

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.

componentattributeaccepted
contact-formdata-fieldsname, email, phone, message
listingsdata-featuredagent, office (or bare true/mine)
listingsdata-property-categoryresidential, commercial, agriculture
collectiondata-featuredtrue, 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.

ClassAcceptedSize limit
imagePNG, JPEG, WebP, AVIF, GIF, SVG25 MB
documentPDF, DOCX25 MB
videoMP4, WebM50 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.

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.

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:

SignalMeaning
X-Mk-Build-Mode: active / endedthe posture right now, on every write — including a rejected one
warnings[].code = limits.build_mode_endedbuild mode ended since your previous write, with the caps now in force. Once per credential
warnings[].code = limits.freeze_approachingpast ~75% of the freeze ceiling, with the number of writes remaining
X-Mk-Limits-Warningthe 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:

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:

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.

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.